From: Jasper St. Pierre Date: Mon, 3 Sep 2012 01:23:50 +0000 (-0300) Subject: Rearchitect the Shell to have a components system X-Git-Tag: archive/raspbian/50.5-1+rpi1~1^2~1013^2^2~5958 X-Git-Url: https://dgit.raspbian.org/%22http://www.example.com/cgi/%22/%22http:/www.example.com/cgi/%22?a=commitdiff_plain;h=2a800e4c;p=gnome-shell.git Rearchitect the Shell to have a components system Components are pieces of the shell code that can be added/removed at runtime, like extension, but are tied more directly to a session mode. The session polkit agent, the network agent, autorun/automount, are all components, keyring, recorder and telepathy client are all now copmonents. https://bugzilla.gnome.org/show_bug.cgi?id=683156 --- diff --git a/js/Makefile.am b/js/Makefile.am index 330098105..351ced1f5 100644 --- a/js/Makefile.am +++ b/js/Makefile.am @@ -1,3 +1,4 @@ +NULL = EXTRA_DIST = misc/config.js.in CLEANFILES = misc/config.js @@ -37,8 +38,6 @@ nobase_dist_js_DATA = \ ui/altTab.js \ ui/appDisplay.js \ ui/appFavorites.js \ - ui/automountManager.js \ - ui/autorunManager.js \ ui/boxpointer.js \ ui/calendar.js \ ui/checkBox.js \ @@ -47,15 +46,14 @@ nobase_dist_js_DATA = \ ui/dateMenu.js \ ui/dnd.js \ ui/endSessionDialog.js \ - ui/environment.js \ ui/extensionSystem.js \ ui/extensionDownloader.js \ + ui/environment.js \ ui/flashspot.js \ ui/ibusCandidatePopup.js\ ui/grabHelper.js \ ui/iconGrid.js \ ui/keyboard.js \ - ui/keyringPrompt.js \ ui/layout.js \ ui/lightbox.js \ ui/lookingGlass.js \ @@ -64,7 +62,6 @@ nobase_dist_js_DATA = \ ui/main.js \ ui/messageTray.js \ ui/modalDialog.js \ - ui/networkAgent.js \ ui/sessionMode.js \ ui/shellEntry.js \ ui/shellMountOperation.js \ @@ -74,7 +71,6 @@ nobase_dist_js_DATA = \ ui/panelMenu.js \ ui/placeDisplay.js \ ui/pointerWatcher.js \ - ui/polkitAuthenticationAgent.js \ ui/popupMenu.js \ ui/remoteSearch.js \ ui/runDialog.js \ @@ -90,7 +86,6 @@ nobase_dist_js_DATA = \ ui/status/power.js \ ui/status/volume.js \ ui/status/bluetooth.js \ - ui/telepathyClient.js \ ui/tweener.js \ ui/unlockDialog.js \ ui/userMenu.js \ @@ -102,4 +97,13 @@ nobase_dist_js_DATA = \ ui/workspaceThumbnail.js \ ui/workspacesView.js \ ui/workspaceSwitcherPopup.js \ - ui/xdndHandler.js + ui/xdndHandler.js \ + ui/components/__init__.js \ + ui/components/autorunManager.js \ + ui/components/automountManager.js \ + ui/components/networkAgent.js \ + ui/components/polkitAgent.js \ + ui/components/recorder.js \ + ui/components/telepathyClient.js \ + ui/components/keyring.js \ + $(NULL) diff --git a/js/ui/automountManager.js b/js/ui/automountManager.js deleted file mode 100644 index a49eb1794..000000000 --- a/js/ui/automountManager.js +++ /dev/null @@ -1,261 +0,0 @@ -// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- - -const Lang = imports.lang; -const Mainloop = imports.mainloop; -const GLib = imports.gi.GLib; -const Gio = imports.gi.Gio; -const Params = imports.misc.params; -const Shell = imports.gi.Shell; - -const GnomeSession = imports.misc.gnomeSession; -const LoginManager = imports.misc.loginManager; -const Main = imports.ui.main; -const ShellMountOperation = imports.ui.shellMountOperation; - -const GNOME_SESSION_AUTOMOUNT_INHIBIT = 16; - -// GSettings keys -const SETTINGS_SCHEMA = 'org.gnome.desktop.media-handling'; -const SETTING_ENABLE_AUTOMOUNT = 'automount'; - -const AUTORUN_EXPIRE_TIMEOUT_SECS = 10; - -const AutomountManager = new Lang.Class({ - Name: 'AutomountManager', - - _init: function() { - this._settings = new Gio.Settings({ schema: SETTINGS_SCHEMA }); - this._volumeQueue = []; - this._session = new GnomeSession.SessionManager(); - this._session.connectSignal('InhibitorAdded', - Lang.bind(this, this._InhibitorsChanged)); - this._session.connectSignal('InhibitorRemoved', - Lang.bind(this, this._InhibitorsChanged)); - this._inhibited = false; - - this._loginManager = LoginManager.getLoginManager(); - - Main.screenShield.connect('lock-status-changed', Lang.bind(this, this._lockStatusChanged)); - - this._volumeMonitor = Gio.VolumeMonitor.get(); - - this._volumeMonitor.connect('volume-added', - Lang.bind(this, - this._onVolumeAdded)); - this._volumeMonitor.connect('volume-removed', - Lang.bind(this, - this._onVolumeRemoved)); - this._volumeMonitor.connect('drive-connected', - Lang.bind(this, - this._onDriveConnected)); - this._volumeMonitor.connect('drive-disconnected', - Lang.bind(this, - this._onDriveDisconnected)); - this._volumeMonitor.connect('drive-eject-button', - Lang.bind(this, - this._onDriveEjectButton)); - - Mainloop.idle_add(Lang.bind(this, this._startupMountAll)); - }, - - _InhibitorsChanged: function(object, senderName, [inhibtor]) { - this._session.IsInhibitedRemote(GNOME_SESSION_AUTOMOUNT_INHIBIT, - Lang.bind(this, - function(result, error) { - if (!error) { - this._inhibited = result[0]; - } - })); - }, - - _lockStatusChanged: function(shield, locked) { - if (!locked) { - this._volumeQueue.forEach(Lang.bind(this, function(volume) { - this._checkAndMountVolume(volume); - })); - } - - // clear the queue anyway - this._volumeQueue = []; - }, - - _startupMountAll: function() { - let volumes = this._volumeMonitor.get_volumes(); - volumes.forEach(Lang.bind(this, function(volume) { - this._checkAndMountVolume(volume, { checkSession: false, - useMountOp: false, - allowAutorun: false }); - })); - - return false; - }, - - _onDriveConnected: function() { - // if we're not in the current ConsoleKit session, - // or screensaver is active, don't play sounds - if (!this._loginManager.sessionActive) - return; - - if (Main.screenShield.locked) - return; - - global.play_theme_sound(0, 'device-added-media'); - }, - - _onDriveDisconnected: function() { - // if we're not in the current ConsoleKit session, - // or screensaver is active, don't play sounds - if (!this._loginManager.sessionActive) - return; - - if (Main.screenShield.locked) - return; - - global.play_theme_sound(0, 'device-removed-media'); - }, - - _onDriveEjectButton: function(monitor, drive) { - // TODO: this code path is not tested, as the GVfs volume monitor - // doesn't emit this signal just yet. - if (!this._loginManager.sessionActive) - return; - - // we force stop/eject in this case, so we don't have to pass a - // mount operation object - if (drive.can_stop()) { - drive.stop - (Gio.MountUnmountFlags.FORCE, null, null, - Lang.bind(this, function(drive, res) { - try { - drive.stop_finish(res); - } catch (e) { - log("Unable to stop the drive after drive-eject-button " + e.toString()); - } - })); - } else if (drive.can_eject()) { - drive.eject_with_operation - (Gio.MountUnmountFlags.FORCE, null, null, - Lang.bind(this, function(drive, res) { - try { - drive.eject_with_operation_finish(res); - } catch (e) { - log("Unable to eject the drive after drive-eject-button " + e.toString()); - } - })); - } - }, - - _onVolumeAdded: function(monitor, volume) { - this._checkAndMountVolume(volume); - }, - - _checkAndMountVolume: function(volume, params) { - params = Params.parse(params, { checkSession: true, - useMountOp: true, - allowAutorun: true }); - - if (params.checkSession) { - // if we're not in the current ConsoleKit session, - // don't attempt automount - if (!this._loginManager.sessionActive) - return; - - if (Main.screenShield.locked) { - if (this._volumeQueue.indexOf(volume) == -1) - this._volumeQueue.push(volume); - - return; - } - } - - if (this._inhibited) - return; - - // Volume is already mounted, don't bother. - if (volume.get_mount()) - return; - - if (!this._settings.get_boolean(SETTING_ENABLE_AUTOMOUNT) || - !volume.should_automount() || - !volume.can_mount()) { - // allow the autorun to run anyway; this can happen if the - // mount gets added programmatically later, even if - // should_automount() or can_mount() are false, like for - // blank optical media. - this._allowAutorun(volume); - this._allowAutorunExpire(volume); - - return; - } - - if (params.useMountOp) { - let operation = new ShellMountOperation.ShellMountOperation(volume); - this._mountVolume(volume, operation, params.allowAutorun); - } else { - this._mountVolume(volume, null, params.allowAutorun); - } - }, - - _mountVolume: function(volume, operation, allowAutorun) { - if (allowAutorun) - this._allowAutorun(volume); - - let mountOp = operation ? operation.mountOp : null; - volume._operation = operation; - - volume.mount(0, mountOp, null, - Lang.bind(this, this._onVolumeMounted)); - }, - - _onVolumeMounted: function(volume, res) { - this._allowAutorunExpire(volume); - - try { - volume.mount_finish(res); - this._closeOperation(volume); - } catch (e) { - // FIXME: we will always get G_IO_ERROR_FAILED from the gvfs udisks - // backend in this case, see - // https://bugs.freedesktop.org/show_bug.cgi?id=51271 - if (e.message.indexOf('No key available with this passphrase') != -1) { - this._reaskPassword(volume); - } else { - if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.FAILED_HANDLED)) - log('Unable to mount volume ' + volume.get_name() + ': ' + e.toString()); - - this._closeOperation(volume); - } - } - }, - - _onVolumeRemoved: function(monitor, volume) { - this._volumeQueue = - this._volumeQueue.filter(function(element) { - return (element != volume); - }); - }, - - _reaskPassword: function(volume) { - let existingDialog = volume._operation ? volume._operation.borrowDialog() : null; - let operation = - new ShellMountOperation.ShellMountOperation(volume, - { existingDialog: existingDialog }); - this._mountVolume(volume, operation); - }, - - _closeOperation: function(volume) { - if (volume._operation) - volume._operation.close(); - }, - - _allowAutorun: function(volume) { - volume.allowAutorun = true; - }, - - _allowAutorunExpire: function(volume) { - Mainloop.timeout_add_seconds(AUTORUN_EXPIRE_TIMEOUT_SECS, function() { - volume.allowAutorun = false; - return false; - }); - } -}); diff --git a/js/ui/autorunManager.js b/js/ui/autorunManager.js deleted file mode 100644 index 2776fbb6d..000000000 --- a/js/ui/autorunManager.js +++ /dev/null @@ -1,595 +0,0 @@ -// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- - -const Lang = imports.lang; -const Gio = imports.gi.Gio; -const St = imports.gi.St; - -const LoginManager = imports.misc.loginManager; -const Main = imports.ui.main; -const MessageTray = imports.ui.messageTray; -const ShellMountOperation = imports.ui.shellMountOperation; - -// GSettings keys -const SETTINGS_SCHEMA = 'org.gnome.desktop.media-handling'; -const SETTING_DISABLE_AUTORUN = 'autorun-never'; -const SETTING_START_APP = 'autorun-x-content-start-app'; -const SETTING_IGNORE = 'autorun-x-content-ignore'; -const SETTING_OPEN_FOLDER = 'autorun-x-content-open-folder'; - -const AutorunSetting = { - RUN: 0, - IGNORE: 1, - FILES: 2, - ASK: 3 -}; - -// misc utils -function shouldAutorunMount(mount, forTransient) { - let root = mount.get_root(); - let volume = mount.get_volume(); - - if (!volume || (!volume.allowAutorun && forTransient)) - return false; - - if (!root.is_native() || isMountRootHidden(root)) - return false; - - return true; -} - -function isMountRootHidden(root) { - let path = root.get_path(); - - // skip any mounts in hidden directory hierarchies - return (path.indexOf('/.') != -1); -} - -function startAppForMount(app, mount) { - let files = []; - let root = mount.get_root(); - let retval = false; - - files.push(root); - - try { - retval = app.launch(files, - global.create_app_launch_context()) - } catch (e) { - log('Unable to launch the application ' + app.get_name() - + ': ' + e.toString()); - } - - return retval; -} - -/******************************************/ - -const HotplugSnifferIface = - - - - -; - -const HotplugSnifferProxy = Gio.DBusProxy.makeProxyWrapper(HotplugSnifferIface); -function HotplugSniffer() { - return new HotplugSnifferProxy(Gio.DBus.session, - 'org.gnome.Shell.HotplugSniffer', - '/org/gnome/Shell/HotplugSniffer'); -} - -const ContentTypeDiscoverer = new Lang.Class({ - Name: 'ContentTypeDiscoverer', - - _init: function(callback) { - this._callback = callback; - }, - - guessContentTypes: function(mount) { - // guess mount's content types using GIO - mount.guess_content_type(false, null, - Lang.bind(this, - this._onContentTypeGuessed)); - }, - - _onContentTypeGuessed: function(mount, res) { - let contentTypes = []; - - try { - contentTypes = mount.guess_content_type_finish(res); - } catch (e) { - log('Unable to guess content types on added mount ' + mount.get_name() - + ': ' + e.toString()); - } - - if (contentTypes.length) { - this._emitCallback(mount, contentTypes); - } else { - let root = mount.get_root(); - - let hotplugSniffer = new HotplugSniffer(); - hotplugSniffer.SniffURIRemote(root.get_uri(), - Lang.bind(this, function([contentTypes]) { - this._emitCallback(mount, contentTypes); - })); - } - }, - - _emitCallback: function(mount, contentTypes) { - if (!contentTypes) - contentTypes = []; - - // we're not interested in win32 software content types here - contentTypes = contentTypes.filter(function(type) { - return (type != 'x-content/win32-software'); - }); - - let apps = []; - contentTypes.forEach(function(type) { - let app = Gio.app_info_get_default_for_type(type, false); - - if (app) - apps.push(app); - }); - - if (apps.length == 0) - apps.push(Gio.app_info_get_default_for_type('inode/directory', false)); - - this._callback(mount, apps, contentTypes); - } -}); - -const AutorunManager = new Lang.Class({ - Name: 'AutorunManager', - - _init: function() { - this._loginManager = LoginManager.getLoginManager(); - - this._volumeMonitor = Gio.VolumeMonitor.get(); - - this._volumeMonitor.connect('mount-added', - Lang.bind(this, - this._onMountAdded)); - this._volumeMonitor.connect('mount-removed', - Lang.bind(this, - this._onMountRemoved)); - - this._transDispatcher = new AutorunTransientDispatcher(); - this._createResidentSource(); - - let mounts = this._volumeMonitor.get_mounts(); - - mounts.forEach(Lang.bind(this, function (mount) { - let discoverer = new ContentTypeDiscoverer(Lang.bind (this, - function (mount, apps) { - this._residentSource.addMount(mount, apps); - })); - - discoverer.guessContentTypes(mount); - })); - }, - - _createResidentSource: function() { - this._residentSource = new AutorunResidentSource(); - this._residentSource.connect('destroy', - Lang.bind(this, - this._createResidentSource)); - }, - - _onMountAdded: function(monitor, mount) { - // don't do anything if our session is not the currently - // active one - if (!this._loginManager.sessionActive) - return; - - let discoverer = new ContentTypeDiscoverer(Lang.bind (this, - function (mount, apps, contentTypes) { - this._transDispatcher.addMount(mount, apps, contentTypes); - this._residentSource.addMount(mount, apps); - })); - - discoverer.guessContentTypes(mount); - }, - - _onMountRemoved: function(monitor, mount) { - this._transDispatcher.removeMount(mount); - this._residentSource.removeMount(mount); - }, - - ejectMount: function(mount) { - let mountOp = new ShellMountOperation.ShellMountOperation(mount); - - // first, see if we have a drive - let drive = mount.get_drive(); - let volume = mount.get_volume(); - - if (drive && - drive.get_start_stop_type() == Gio.DriveStartStopType.SHUTDOWN && - drive.can_stop()) { - drive.stop(0, mountOp.mountOp, null, - Lang.bind(this, this._onStop)); - } else { - if (mount.can_eject()) { - mount.eject_with_operation(0, mountOp.mountOp, null, - Lang.bind(this, this._onEject)); - } else if (volume && volume.can_eject()) { - volume.eject_with_operation(0, mountOp.mountOp, null, - Lang.bind(this, this._onEject)); - } else if (drive && drive.can_eject()) { - drive.eject_with_operation(0, mountOp.mountOp, null, - Lang.bind(this, this._onEject)); - } else if (mount.can_unmount()) { - mount.unmount_with_operation(0, mountOp.mountOp, null, - Lang.bind(this, this._onUnmount)); - } - } - }, - - _onUnmount: function(mount, res) { - try { - mount.unmount_with_operation_finish(res); - } catch (e) { - if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.FAILED_HANDLED)) - log('Unable to eject the mount ' + mount.get_name() - + ': ' + e.toString()); - } - }, - - _onEject: function(source, res) { - try { - source.eject_with_operation_finish(res); - } catch (e) { - if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.FAILED_HANDLED)) - log('Unable to eject the drive ' + source.get_name() - + ': ' + e.toString()); - } - }, - - _onStop: function(drive, res) { - try { - drive.stop_finish(res); - } catch (e) { - if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.FAILED_HANDLED)) - log('Unable to stop the drive ' + drive.get_name() - + ': ' + e.toString()); - } - }, -}); - -const AutorunResidentSource = new Lang.Class({ - Name: 'AutorunResidentSource', - Extends: MessageTray.Source, - - _init: function() { - this.parent(_("Removable Devices"), 'media-removable'); - this.showInLockScreen = false; - - this._mounts = []; - - this._notification = new AutorunResidentNotification(this); - }, - - addMount: function(mount, apps) { - if (!shouldAutorunMount(mount, false)) - return; - - let filtered = this._mounts.filter(function (element) { - return (element.mount == mount); - }); - - if (filtered.length != 0) - return; - - let element = { mount: mount, apps: apps }; - this._mounts.push(element); - this._redisplay(); - }, - - removeMount: function(mount) { - this._mounts = - this._mounts.filter(function (element) { - return (element.mount != mount); - }); - - this._redisplay(); - }, - - _redisplay: function() { - if (this._mounts.length == 0) { - this._notification.destroy(); - this.destroy(); - - return; - } - - this._notification.updateForMounts(this._mounts); - - // add ourselves as a source, and push the notification - if (!Main.messageTray.contains(this)) { - Main.messageTray.add(this); - this.pushNotification(this._notification); - } - } -}); - -const AutorunResidentNotification = new Lang.Class({ - Name: 'AutorunResidentNotification', - Extends: MessageTray.Notification, - - _init: function(source) { - this.parent(source, source.title, null, { customContent: true }); - - // set the notification as resident - this.setResident(true); - - this._layout = new St.BoxLayout ({ style_class: 'hotplug-resident-box', - vertical: true }); - - this.addActor(this._layout, - { x_expand: true, - x_fill: true }); - }, - - updateForMounts: function(mounts) { - // remove all the layout content - this._layout.destroy_all_children(); - - for (let idx = 0; idx < mounts.length; idx++) { - let element = mounts[idx]; - - let actor = this._itemForMount(element.mount, element.apps); - this._layout.add(actor, { x_fill: true, - expand: true }); - } - }, - - _itemForMount: function(mount, apps) { - let item = new St.BoxLayout(); - - // prepare the mount button content - let mountLayout = new St.BoxLayout(); - - let mountIcon = new St.Icon({ gicon: mount.get_icon(), - style_class: 'hotplug-resident-mount-icon' }); - mountLayout.add_actor(mountIcon); - - let labelBin = new St.Bin({ y_align: St.Align.MIDDLE }); - let mountLabel = - new St.Label({ text: mount.get_name(), - style_class: 'hotplug-resident-mount-label', - track_hover: true, - reactive: true }); - labelBin.add_actor(mountLabel); - mountLayout.add_actor(labelBin); - - let mountButton = new St.Button({ child: mountLayout, - x_align: St.Align.START, - x_fill: true, - style_class: 'hotplug-resident-mount', - button_mask: St.ButtonMask.ONE }); - item.add(mountButton, { x_align: St.Align.START, - expand: true }); - - let ejectIcon = - new St.Icon({ icon_name: 'media-eject', - style_class: 'hotplug-resident-eject-icon' }); - - let ejectButton = - new St.Button({ style_class: 'hotplug-resident-eject-button', - button_mask: St.ButtonMask.ONE, - child: ejectIcon }); - item.add(ejectButton, { x_align: St.Align.END }); - - // now connect signals - mountButton.connect('clicked', Lang.bind(this, function(actor, event) { - startAppForMount(apps[0], mount); - })); - - ejectButton.connect('clicked', Lang.bind(this, function() { - Main.autorunManager.ejectMount(mount); - })); - - return item; - }, -}); - -const AutorunTransientDispatcher = new Lang.Class({ - Name: 'AutorunTransientDispatcher', - - _init: function() { - this._sources = []; - this._settings = new Gio.Settings({ schema: SETTINGS_SCHEMA }); - }, - - _getAutorunSettingForType: function(contentType) { - let runApp = this._settings.get_strv(SETTING_START_APP); - if (runApp.indexOf(contentType) != -1) - return AutorunSetting.RUN; - - let ignore = this._settings.get_strv(SETTING_IGNORE); - if (ignore.indexOf(contentType) != -1) - return AutorunSetting.IGNORE; - - let openFiles = this._settings.get_strv(SETTING_OPEN_FOLDER); - if (openFiles.indexOf(contentType) != -1) - return AutorunSetting.FILES; - - return AutorunSetting.ASK; - }, - - _getSourceForMount: function(mount) { - let filtered = - this._sources.filter(function (source) { - return (source.mount == mount); - }); - - // we always make sure not to add two sources for the same - // mount in addMount(), so it's safe to assume filtered.length - // is always either 1 or 0. - if (filtered.length == 1) - return filtered[0]; - - return null; - }, - - _addSource: function(mount, apps) { - // if we already have a source showing for this - // mount, return - if (this._getSourceForMount(mount)) - return; - - // add a new source - this._sources.push(new AutorunTransientSource(mount, apps)); - }, - - addMount: function(mount, apps, contentTypes) { - // if autorun is disabled globally, return - if (this._settings.get_boolean(SETTING_DISABLE_AUTORUN)) - return; - - // if the mount doesn't want to be autorun, return - if (!shouldAutorunMount(mount, true)) - return; - - let setting = this._getAutorunSettingForType(contentTypes[0]); - - // check at the settings for the first content type - // to see whether we should ask - if (setting == AutorunSetting.IGNORE) - return; // return right away - - let success = false; - let app = null; - - if (setting == AutorunSetting.RUN) { - app = Gio.app_info_get_default_for_type(contentTypes[0], false); - } else if (setting == AutorunSetting.FILES) { - app = Gio.app_info_get_default_for_type('inode/directory', false); - } - - if (app) - success = startAppForMount(app, mount); - - // we fallback here also in case the settings did not specify 'ask', - // but we failed launching the default app or the default file manager - if (!success) - this._addSource(mount, apps); - }, - - removeMount: function(mount) { - let source = this._getSourceForMount(mount); - - // if we aren't tracking this mount, don't do anything - if (!source) - return; - - // destroy the notification source - source.destroy(); - } -}); - -const AutorunTransientSource = new Lang.Class({ - Name: 'AutorunTransientSource', - Extends: MessageTray.Source, - - _init: function(mount, apps) { - this.mount = mount; - this.apps = apps; - - this.parent(mount.get_name()); - - this._notification = new AutorunTransientNotification(this); - - // add ourselves as a source, and popup the notification - Main.messageTray.add(this); - this.notify(this._notification); - }, - - createIcon: function(size) { - return new St.Icon({ gicon: this.mount.get_icon(), - icon_size: size }); - } -}); - -const AutorunTransientNotification = new Lang.Class({ - Name: 'AutorunTransientNotification', - Extends: MessageTray.Notification, - - _init: function(source) { - this.parent(source, source.title, null, { customContent: true }); - - this._box = new St.BoxLayout({ style_class: 'hotplug-transient-box', - vertical: true }); - this.addActor(this._box); - - this._mount = source.mount; - - source.apps.forEach(Lang.bind(this, function (app) { - let actor = this._buttonForApp(app); - - if (actor) - this._box.add(actor, { x_fill: true, - x_align: St.Align.START }); - })); - - this._box.add(this._buttonForEject(), { x_fill: true, - x_align: St.Align.START }); - - // set the notification to transient and urgent, so that it - // expands out - this.setTransient(true); - this.setUrgency(MessageTray.Urgency.CRITICAL); - }, - - _buttonForApp: function(app) { - let box = new St.BoxLayout(); - let icon = new St.Icon({ gicon: app.get_icon(), - style_class: 'hotplug-notification-item-icon' }); - box.add(icon); - - let label = new St.Bin({ y_align: St.Align.MIDDLE, - child: new St.Label - ({ text: _("Open with %s").format(app.get_name()) }) - }); - box.add(label); - - let button = new St.Button({ child: box, - x_fill: true, - x_align: St.Align.START, - button_mask: St.ButtonMask.ONE, - style_class: 'hotplug-notification-item' }); - - button.connect('clicked', Lang.bind(this, function() { - startAppForMount(app, this._mount); - this.destroy(); - })); - - return button; - }, - - _buttonForEject: function() { - let box = new St.BoxLayout(); - let icon = new St.Icon({ icon_name: 'media-eject', - style_class: 'hotplug-notification-item-icon' }); - box.add(icon); - - let label = new St.Bin({ y_align: St.Align.MIDDLE, - child: new St.Label - ({ text: _("Eject") }) - }); - box.add(label); - - let button = new St.Button({ child: box, - x_fill: true, - x_align: St.Align.START, - button_mask: St.ButtonMask.ONE, - style_class: 'hotplug-notification-item' }); - - button.connect('clicked', Lang.bind(this, function() { - Main.autorunManager.ejectMount(this._mount); - })); - - return button; - } -}); - diff --git a/js/ui/components/__init__.js b/js/ui/components/__init__.js new file mode 100644 index 000000000..763c87d7b --- /dev/null +++ b/js/ui/components/__init__.js @@ -0,0 +1,61 @@ + +const Lang = imports.lang; +const Main = imports.ui.main; + +const ComponentManager = new Lang.Class({ + Name: 'ComponentManager', + + _init: function() { + this._allComponents = {}; + this._enabledComponents = []; + + Main.sessionMode.connect('updated', Lang.bind(this, this._sessionUpdated)); + this._sessionUpdated(); + }, + + _sessionUpdated: function() { + let newEnabledComponents = Main.sessionMode.components; + + newEnabledComponents.filter(Lang.bind(this, function(name) { + return this._enabledComponents.indexOf(name) == -1; + })).forEach(Lang.bind(this, function(name) { + this._enableComponent(name); + })); + + this._enabledComponents.filter(Lang.bind(this, function(name) { + return newEnabledComponents.indexOf(name) == -1; + })).forEach(Lang.bind(this, function(name) { + this._disableComponent(name); + })); + + this._enabledComponents = newEnabledComponents; + }, + + _importComponent: function(name) { + let module = imports.ui.components[name]; + return module.Component; + }, + + _ensureComponent: function(name) { + let component = this._allComponents[name]; + if (component) + return component; + + let constructor = this._importComponent(name); + component = new constructor(); + this._allComponents[name] = component; + return component; + }, + + _enableComponent: function(name) { + let component = this._ensureComponent(name); + component.enable(); + }, + + _disableComponent: function(name) { + let component = this._allComponents[name]; + if (component == null) + return; + component.disable(); + } +}); diff --git a/js/ui/components/automountManager.js b/js/ui/components/automountManager.js new file mode 100644 index 000000000..9081e80e6 --- /dev/null +++ b/js/ui/components/automountManager.js @@ -0,0 +1,241 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- + +const Lang = imports.lang; +const Mainloop = imports.mainloop; +const GLib = imports.gi.GLib; +const Gio = imports.gi.Gio; +const Params = imports.misc.params; +const Shell = imports.gi.Shell; + +const GnomeSession = imports.misc.gnomeSession; +const LoginManager = imports.misc.loginManager; +const Main = imports.ui.main; +const ShellMountOperation = imports.ui.shellMountOperation; + +const GNOME_SESSION_AUTOMOUNT_INHIBIT = 16; + +// GSettings keys +const SETTINGS_SCHEMA = 'org.gnome.desktop.media-handling'; +const SETTING_ENABLE_AUTOMOUNT = 'automount'; + +const AUTORUN_EXPIRE_TIMEOUT_SECS = 10; + +const AutomountManager = new Lang.Class({ + Name: 'AutomountManager', + + _init: function() { + this._settings = new Gio.Settings({ schema: SETTINGS_SCHEMA }); + this._volumeQueue = []; + this._session = new GnomeSession.SessionManager(); + this._session.connectSignal('InhibitorAdded', + Lang.bind(this, this._InhibitorsChanged)); + this._session.connectSignal('InhibitorRemoved', + Lang.bind(this, this._InhibitorsChanged)); + this._inhibited = false; + + this._loginManager = LoginManager.getLoginManager(); + this._volumeMonitor = Gio.VolumeMonitor.get(); + }, + + enable: function() { + this._volumeAddedId = this._volumeMonitor.connect('volume-added', Lang.bind(this, this._onVolumeAdded)); + this._volumeRemovedId = this._volumeMonitor.connect('volume-removed', Lang.bind(this, this._onVolumeRemoved)); + this._driveConnectedId = this._volumeMonitor.connect('drive-connected', Lang.bind(this, this._onDriveConnected)); + this._driveDisconnectedId = this._volumeMonitor.connect('drive-disconnected', Lang.bind(this, this._onDriveDisconnected)); + this._driveEjectButtonId = this._volumeMonitor.connect('drive-eject-button', Lang.bind(this, this._onDriveEjectButton)); + + this._mountAllId = Mainloop.idle_add(Lang.bind(this, this._startupMountAll)); + }, + + disable: function() { + this._volumeMonitor.disconnect(this._volumeAddedId); + this._volumeMonitor.disconnect(this._volumeRemovedId); + this._volumeMonitor.disconnect(this._driveConnectedId); + this._volumeMonitor.disconnect(this._driveDisconnectedId); + this._volumeMonitor.disconnect(this._driveEjectButtonId); + + if (this._mountAllId > 0) { + Mainloop.source_remove(this._mountAllId); + this._mountAllId = 0; + } + }, + + _InhibitorsChanged: function(object, senderName, [inhibtor]) { + this._session.IsInhibitedRemote(GNOME_SESSION_AUTOMOUNT_INHIBIT, + Lang.bind(this, + function(result, error) { + if (!error) { + this._inhibited = result[0]; + } + })); + }, + + _startupMountAll: function() { + let volumes = this._volumeMonitor.get_volumes(); + volumes.forEach(Lang.bind(this, function(volume) { + this._checkAndMountVolume(volume, { checkSession: false, + useMountOp: false, + allowAutorun: false }); + })); + + this._mountAllId = 0; + return false; + }, + + _onDriveConnected: function() { + // if we're not in the current ConsoleKit session, + // or screensaver is active, don't play sounds + if (!this._loginManager.sessionActive) + return; + + global.play_theme_sound(0, 'device-added-media'); + }, + + _onDriveDisconnected: function() { + // if we're not in the current ConsoleKit session, + // or screensaver is active, don't play sounds + if (!this._loginManager.sessionActive) + return; + + global.play_theme_sound(0, 'device-removed-media'); + }, + + _onDriveEjectButton: function(monitor, drive) { + // TODO: this code path is not tested, as the GVfs volume monitor + // doesn't emit this signal just yet. + if (!this._loginManager.sessionActive) + return; + + // we force stop/eject in this case, so we don't have to pass a + // mount operation object + if (drive.can_stop()) { + drive.stop + (Gio.MountUnmountFlags.FORCE, null, null, + Lang.bind(this, function(drive, res) { + try { + drive.stop_finish(res); + } catch (e) { + log("Unable to stop the drive after drive-eject-button " + e.toString()); + } + })); + } else if (drive.can_eject()) { + drive.eject_with_operation + (Gio.MountUnmountFlags.FORCE, null, null, + Lang.bind(this, function(drive, res) { + try { + drive.eject_with_operation_finish(res); + } catch (e) { + log("Unable to eject the drive after drive-eject-button " + e.toString()); + } + })); + } + }, + + _onVolumeAdded: function(monitor, volume) { + this._checkAndMountVolume(volume); + }, + + _checkAndMountVolume: function(volume, params) { + params = Params.parse(params, { checkSession: true, + useMountOp: true, + allowAutorun: true }); + + if (params.checkSession) { + // if we're not in the current ConsoleKit session, + // don't attempt automount + if (!this._loginManager.sessionActive) + return; + } + + if (this._inhibited) + return; + + // Volume is already mounted, don't bother. + if (volume.get_mount()) + return; + + if (!this._settings.get_boolean(SETTING_ENABLE_AUTOMOUNT) || + !volume.should_automount() || + !volume.can_mount()) { + // allow the autorun to run anyway; this can happen if the + // mount gets added programmatically later, even if + // should_automount() or can_mount() are false, like for + // blank optical media. + this._allowAutorun(volume); + this._allowAutorunExpire(volume); + + return; + } + + if (params.useMountOp) { + let operation = new ShellMountOperation.ShellMountOperation(volume); + this._mountVolume(volume, operation, params.allowAutorun); + } else { + this._mountVolume(volume, null, params.allowAutorun); + } + }, + + _mountVolume: function(volume, operation, allowAutorun) { + if (allowAutorun) + this._allowAutorun(volume); + + let mountOp = operation ? operation.mountOp : null; + volume._operation = operation; + + volume.mount(0, mountOp, null, + Lang.bind(this, this._onVolumeMounted)); + }, + + _onVolumeMounted: function(volume, res) { + this._allowAutorunExpire(volume); + + try { + volume.mount_finish(res); + this._closeOperation(volume); + } catch (e) { + // FIXME: we will always get G_IO_ERROR_FAILED from the gvfs udisks + // backend in this case, see + // https://bugs.freedesktop.org/show_bug.cgi?id=51271 + if (e.message.indexOf('No key available with this passphrase') != -1) { + this._reaskPassword(volume); + } else { + if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.FAILED_HANDLED)) + log('Unable to mount volume ' + volume.get_name() + ': ' + e.toString()); + + this._closeOperation(volume); + } + } + }, + + _onVolumeRemoved: function(monitor, volume) { + this._volumeQueue = + this._volumeQueue.filter(function(element) { + return (element != volume); + }); + }, + + _reaskPassword: function(volume) { + let existingDialog = volume._operation ? volume._operation.borrowDialog() : null; + let operation = + new ShellMountOperation.ShellMountOperation(volume, + { existingDialog: existingDialog }); + this._mountVolume(volume, operation); + }, + + _closeOperation: function(volume) { + if (volume._operation) + volume._operation.close(); + }, + + _allowAutorun: function(volume) { + volume.allowAutorun = true; + }, + + _allowAutorunExpire: function(volume) { + Mainloop.timeout_add_seconds(AUTORUN_EXPIRE_TIMEOUT_SECS, function() { + volume.allowAutorun = false; + return false; + }); + } +}); +const Component = AutomountManager; diff --git a/js/ui/components/autorunManager.js b/js/ui/components/autorunManager.js new file mode 100644 index 000000000..3434e9b88 --- /dev/null +++ b/js/ui/components/autorunManager.js @@ -0,0 +1,601 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- + +const Lang = imports.lang; +const Gio = imports.gi.Gio; +const St = imports.gi.St; + +const LoginManager = imports.misc.loginManager; +const Main = imports.ui.main; +const MessageTray = imports.ui.messageTray; +const ShellMountOperation = imports.ui.shellMountOperation; + +// GSettings keys +const SETTINGS_SCHEMA = 'org.gnome.desktop.media-handling'; +const SETTING_DISABLE_AUTORUN = 'autorun-never'; +const SETTING_START_APP = 'autorun-x-content-start-app'; +const SETTING_IGNORE = 'autorun-x-content-ignore'; +const SETTING_OPEN_FOLDER = 'autorun-x-content-open-folder'; + +const AutorunSetting = { + RUN: 0, + IGNORE: 1, + FILES: 2, + ASK: 3 +}; + +// misc utils +function shouldAutorunMount(mount, forTransient) { + let root = mount.get_root(); + let volume = mount.get_volume(); + + if (!volume || (!volume.allowAutorun && forTransient)) + return false; + + if (!root.is_native() || isMountRootHidden(root)) + return false; + + return true; +} + +function isMountRootHidden(root) { + let path = root.get_path(); + + // skip any mounts in hidden directory hierarchies + return (path.indexOf('/.') != -1); +} + +function startAppForMount(app, mount) { + let files = []; + let root = mount.get_root(); + let retval = false; + + files.push(root); + + try { + retval = app.launch(files, + global.create_app_launch_context()) + } catch (e) { + log('Unable to launch the application ' + app.get_name() + + ': ' + e.toString()); + } + + return retval; +} + +/******************************************/ + +const HotplugSnifferIface = + + + + +; + +const HotplugSnifferProxy = Gio.DBusProxy.makeProxyWrapper(HotplugSnifferIface); +function HotplugSniffer() { + return new HotplugSnifferProxy(Gio.DBus.session, + 'org.gnome.Shell.HotplugSniffer', + '/org/gnome/Shell/HotplugSniffer'); +} + +const ContentTypeDiscoverer = new Lang.Class({ + Name: 'ContentTypeDiscoverer', + + _init: function(callback) { + this._callback = callback; + }, + + guessContentTypes: function(mount) { + // guess mount's content types using GIO + mount.guess_content_type(false, null, + Lang.bind(this, + this._onContentTypeGuessed)); + }, + + _onContentTypeGuessed: function(mount, res) { + let contentTypes = []; + + try { + contentTypes = mount.guess_content_type_finish(res); + } catch (e) { + log('Unable to guess content types on added mount ' + mount.get_name() + + ': ' + e.toString()); + } + + if (contentTypes.length) { + this._emitCallback(mount, contentTypes); + } else { + let root = mount.get_root(); + + let hotplugSniffer = new HotplugSniffer(); + hotplugSniffer.SniffURIRemote(root.get_uri(), + Lang.bind(this, function([contentTypes]) { + this._emitCallback(mount, contentTypes); + })); + } + }, + + _emitCallback: function(mount, contentTypes) { + if (!contentTypes) + contentTypes = []; + + // we're not interested in win32 software content types here + contentTypes = contentTypes.filter(function(type) { + return (type != 'x-content/win32-software'); + }); + + let apps = []; + contentTypes.forEach(function(type) { + let app = Gio.app_info_get_default_for_type(type, false); + + if (app) + apps.push(app); + }); + + if (apps.length == 0) + apps.push(Gio.app_info_get_default_for_type('inode/directory', false)); + + this._callback(mount, apps, contentTypes); + } +}); + +const AutorunManager = new Lang.Class({ + Name: 'AutorunManager', + + _init: function() { + this._loginManager = LoginManager.getLoginManager(); + + this._volumeMonitor = Gio.VolumeMonitor.get(); + + this._transDispatcher = new AutorunTransientDispatcher(this); + }, + + enable: function() { + this._residentSource = new AutorunResidentSource(this); + this._scanMounts(); + + this._mountAddedId = this._volumeMonitor.connect('mount-added', Lang.bind(this, this._onMountAdded)); + this._mountRemovedId = this._volumeMonitor.connect('mount-removed', Lang.bind(this, this._onMountRemoved)); + }, + + disable: function() { + this._residentSource.destroy(); + this._volumeMonitor.disconnect(this._mountAddedId); + this._volumeMonitor.disconnect(this._mountRemovedId); + }, + + _scanMounts: function() { + let mounts = this._volumeMonitor.get_mounts(); + mounts.forEach(Lang.bind(this, function (mount) { + let discoverer = new ContentTypeDiscoverer(Lang.bind (this, + function (mount, apps) { + this._residentSource.addMount(mount, apps); + })); + + discoverer.guessContentTypes(mount); + })); + }, + + _onMountAdded: function(monitor, mount) { + // don't do anything if our session is not the currently + // active one + if (!this._loginManager.sessionActive) + return; + + let discoverer = new ContentTypeDiscoverer(Lang.bind (this, + function (mount, apps, contentTypes) { + this._transDispatcher.addMount(mount, apps, contentTypes); + this._residentSource.addMount(mount, apps); + })); + + discoverer.guessContentTypes(mount); + }, + + _onMountRemoved: function(monitor, mount) { + this._transDispatcher.removeMount(mount); + this._residentSource.removeMount(mount); + }, + + ejectMount: function(mount) { + let mountOp = new ShellMountOperation.ShellMountOperation(mount); + + // first, see if we have a drive + let drive = mount.get_drive(); + let volume = mount.get_volume(); + + if (drive && + drive.get_start_stop_type() == Gio.DriveStartStopType.SHUTDOWN && + drive.can_stop()) { + drive.stop(0, mountOp.mountOp, null, + Lang.bind(this, this._onStop)); + } else { + if (mount.can_eject()) { + mount.eject_with_operation(0, mountOp.mountOp, null, + Lang.bind(this, this._onEject)); + } else if (volume && volume.can_eject()) { + volume.eject_with_operation(0, mountOp.mountOp, null, + Lang.bind(this, this._onEject)); + } else if (drive && drive.can_eject()) { + drive.eject_with_operation(0, mountOp.mountOp, null, + Lang.bind(this, this._onEject)); + } else if (mount.can_unmount()) { + mount.unmount_with_operation(0, mountOp.mountOp, null, + Lang.bind(this, this._onUnmount)); + } + } + }, + + _onUnmount: function(mount, res) { + try { + mount.unmount_with_operation_finish(res); + } catch (e) { + if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.FAILED_HANDLED)) + log('Unable to eject the mount ' + mount.get_name() + + ': ' + e.toString()); + } + }, + + _onEject: function(source, res) { + try { + source.eject_with_operation_finish(res); + } catch (e) { + if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.FAILED_HANDLED)) + log('Unable to eject the drive ' + source.get_name() + + ': ' + e.toString()); + } + }, + + _onStop: function(drive, res) { + try { + drive.stop_finish(res); + } catch (e) { + if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.FAILED_HANDLED)) + log('Unable to stop the drive ' + drive.get_name() + + ': ' + e.toString()); + } + }, +}); + +const AutorunResidentSource = new Lang.Class({ + Name: 'AutorunResidentSource', + Extends: MessageTray.Source, + + _init: function(manager) { + this.parent(_("Removable Devices"), 'media-removable'); + this.showInLockScreen = false; + + this._mounts = []; + + this._notification = new AutorunResidentNotification(this._manager, this); + this._manager = manager; + }, + + addMount: function(mount, apps) { + if (!shouldAutorunMount(mount, false)) + return; + + let filtered = this._mounts.filter(function (element) { + return (element.mount == mount); + }); + + if (filtered.length != 0) + return; + + let element = { mount: mount, apps: apps }; + this._mounts.push(element); + this._redisplay(); + }, + + removeMount: function(mount) { + this._mounts = + this._mounts.filter(function (element) { + return (element.mount != mount); + }); + + this._redisplay(); + }, + + _redisplay: function() { + if (this._mounts.length == 0) { + this._notification.destroy(); + this.destroy(); + + return; + } + + this._notification.updateForMounts(this._mounts); + + // add ourselves as a source, and push the notification + if (!Main.messageTray.contains(this)) { + Main.messageTray.add(this); + this.pushNotification(this._notification); + } + } +}); + +const AutorunResidentNotification = new Lang.Class({ + Name: 'AutorunResidentNotification', + Extends: MessageTray.Notification, + + _init: function(manager, source) { + this.parent(source, source.title, null, { customContent: true }); + + // set the notification as resident + this.setResident(true); + + this._layout = new St.BoxLayout ({ style_class: 'hotplug-resident-box', + vertical: true }); + this._manager = manager; + + this.addActor(this._layout, + { x_expand: true, + x_fill: true }); + }, + + updateForMounts: function(mounts) { + // remove all the layout content + this._layout.destroy_all_children(); + + for (let idx = 0; idx < mounts.length; idx++) { + let element = mounts[idx]; + + let actor = this._itemForMount(element.mount, element.apps); + this._layout.add(actor, { x_fill: true, + expand: true }); + } + }, + + _itemForMount: function(mount, apps) { + let item = new St.BoxLayout(); + + // prepare the mount button content + let mountLayout = new St.BoxLayout(); + + let mountIcon = new St.Icon({ gicon: mount.get_icon(), + style_class: 'hotplug-resident-mount-icon' }); + mountLayout.add_actor(mountIcon); + + let labelBin = new St.Bin({ y_align: St.Align.MIDDLE }); + let mountLabel = + new St.Label({ text: mount.get_name(), + style_class: 'hotplug-resident-mount-label', + track_hover: true, + reactive: true }); + labelBin.add_actor(mountLabel); + mountLayout.add_actor(labelBin); + + let mountButton = new St.Button({ child: mountLayout, + x_align: St.Align.START, + x_fill: true, + style_class: 'hotplug-resident-mount', + button_mask: St.ButtonMask.ONE }); + item.add(mountButton, { x_align: St.Align.START, + expand: true }); + + let ejectIcon = + new St.Icon({ icon_name: 'media-eject', + style_class: 'hotplug-resident-eject-icon' }); + + let ejectButton = + new St.Button({ style_class: 'hotplug-resident-eject-button', + button_mask: St.ButtonMask.ONE, + child: ejectIcon }); + item.add(ejectButton, { x_align: St.Align.END }); + + // now connect signals + mountButton.connect('clicked', Lang.bind(this, function(actor, event) { + this._manager.startAppForMount(apps[0], mount); + })); + + ejectButton.connect('clicked', Lang.bind(this, function() { + this._manager.ejectMount(mount); + })); + + return item; + }, +}); + +const AutorunTransientDispatcher = new Lang.Class({ + Name: 'AutorunTransientDispatcher', + + _init: function(manager) { + this._manager = manager; + this._sources = []; + this._settings = new Gio.Settings({ schema: SETTINGS_SCHEMA }); + }, + + _getAutorunSettingForType: function(contentType) { + let runApp = this._settings.get_strv(SETTING_START_APP); + if (runApp.indexOf(contentType) != -1) + return AutorunSetting.RUN; + + let ignore = this._settings.get_strv(SETTING_IGNORE); + if (ignore.indexOf(contentType) != -1) + return AutorunSetting.IGNORE; + + let openFiles = this._settings.get_strv(SETTING_OPEN_FOLDER); + if (openFiles.indexOf(contentType) != -1) + return AutorunSetting.FILES; + + return AutorunSetting.ASK; + }, + + _getSourceForMount: function(mount) { + let filtered = + this._sources.filter(function (source) { + return (source.mount == mount); + }); + + // we always make sure not to add two sources for the same + // mount in addMount(), so it's safe to assume filtered.length + // is always either 1 or 0. + if (filtered.length == 1) + return filtered[0]; + + return null; + }, + + _addSource: function(mount, apps) { + // if we already have a source showing for this + // mount, return + if (this._getSourceForMount(mount)) + return; + + // add a new source + this._sources.push(new AutorunTransientSource(this._manager, mount, apps)); + }, + + addMount: function(mount, apps, contentTypes) { + // if autorun is disabled globally, return + if (this._settings.get_boolean(SETTING_DISABLE_AUTORUN)) + return; + + // if the mount doesn't want to be autorun, return + if (!shouldAutorunMount(mount, true)) + return; + + let setting = this._getAutorunSettingForType(contentTypes[0]); + + // check at the settings for the first content type + // to see whether we should ask + if (setting == AutorunSetting.IGNORE) + return; // return right away + + let success = false; + let app = null; + + if (setting == AutorunSetting.RUN) { + app = Gio.app_info_get_default_for_type(contentTypes[0], false); + } else if (setting == AutorunSetting.FILES) { + app = Gio.app_info_get_default_for_type('inode/directory', false); + } + + if (app) + success = startAppForMount(app, mount); + + // we fallback here also in case the settings did not specify 'ask', + // but we failed launching the default app or the default file manager + if (!success) + this._addSource(mount, apps); + }, + + removeMount: function(mount) { + let source = this._getSourceForMount(mount); + + // if we aren't tracking this mount, don't do anything + if (!source) + return; + + // destroy the notification source + source.destroy(); + } +}); + +const AutorunTransientSource = new Lang.Class({ + Name: 'AutorunTransientSource', + Extends: MessageTray.Source, + + _init: function(manager, mount, apps) { + this._manager = manager; + this.mount = mount; + this.apps = apps; + + this.parent(mount.get_name()); + + this._notification = new AutorunTransientNotification(this._manager, this); + + // add ourselves as a source, and popup the notification + Main.messageTray.add(this); + this.notify(this._notification); + }, + + createIcon: function(size) { + return new St.Icon({ gicon: this.mount.get_icon(), + icon_size: size }); + } +}); + +const AutorunTransientNotification = new Lang.Class({ + Name: 'AutorunTransientNotification', + Extends: MessageTray.Notification, + + _init: function(manager, source) { + this.parent(source, source.title, null, { customContent: true }); + + this._manager = manager; + this._box = new St.BoxLayout({ style_class: 'hotplug-transient-box', + vertical: true }); + this.addActor(this._box); + + this._mount = source.mount; + + source.apps.forEach(Lang.bind(this, function (app) { + let actor = this._buttonForApp(app); + + if (actor) + this._box.add(actor, { x_fill: true, + x_align: St.Align.START }); + })); + + this._box.add(this._buttonForEject(), { x_fill: true, + x_align: St.Align.START }); + + // set the notification to transient and urgent, so that it + // expands out + this.setTransient(true); + this.setUrgency(MessageTray.Urgency.CRITICAL); + }, + + _buttonForApp: function(app) { + let box = new St.BoxLayout(); + let icon = new St.Icon({ gicon: app.get_icon(), + style_class: 'hotplug-notification-item-icon' }); + box.add(icon); + + let label = new St.Bin({ y_align: St.Align.MIDDLE, + child: new St.Label + ({ text: _("Open with %s").format(app.get_name()) }) + }); + box.add(label); + + let button = new St.Button({ child: box, + x_fill: true, + x_align: St.Align.START, + button_mask: St.ButtonMask.ONE, + style_class: 'hotplug-notification-item' }); + + button.connect('clicked', Lang.bind(this, function() { + startAppForMount(app, this._mount); + this.destroy(); + })); + + return button; + }, + + _buttonForEject: function() { + let box = new St.BoxLayout(); + let icon = new St.Icon({ icon_name: 'media-eject', + style_class: 'hotplug-notification-item-icon' }); + box.add(icon); + + let label = new St.Bin({ y_align: St.Align.MIDDLE, + child: new St.Label + ({ text: _("Eject") }) + }); + box.add(label); + + let button = new St.Button({ child: box, + x_fill: true, + x_align: St.Align.START, + button_mask: St.ButtonMask.ONE, + style_class: 'hotplug-notification-item' }); + + button.connect('clicked', Lang.bind(this, function() { + this._manager.ejectMount(this._mount); + })); + + return button; + } +}); + +const Component = AutorunManager; diff --git a/js/ui/components/keyring.js b/js/ui/components/keyring.js new file mode 100644 index 000000000..dfa7cc007 --- /dev/null +++ b/js/ui/components/keyring.js @@ -0,0 +1,226 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- + +const Lang = imports.lang; +const Shell = imports.gi.Shell; +const Clutter = imports.gi.Clutter; +const St = imports.gi.St; +const Pango = imports.gi.Pango; +const Gio = imports.gi.Gio; +const GObject = imports.gi.GObject; +const Gcr = imports.gi.Gcr; + +const ModalDialog = imports.ui.modalDialog; +const ShellEntry = imports.ui.shellEntry; +const CheckBox = imports.ui.checkBox; + +let prompter = null; + +const KeyringDialog = new Lang.Class({ + Name: 'KeyringDialog', + Extends: ModalDialog.ModalDialog, + + _init: function() { + this.parent({ styleClass: 'prompt-dialog' }); + + this.prompt = new Shell.KeyringPrompt(); + this.prompt.connect('show-password', Lang.bind(this, this._onShowPassword)); + this.prompt.connect('show-confirm', Lang.bind(this, this._onShowConfirm)); + this.prompt.connect('hide-prompt', Lang.bind(this, this._onHidePrompt)); + + let mainContentBox = new St.BoxLayout({ style_class: 'prompt-dialog-main-layout', + vertical: false }); + this.contentLayout.add(mainContentBox); + + let icon = new St.Icon({ icon_name: 'dialog-password-symbolic' }); + mainContentBox.add(icon, + { x_fill: true, + y_fill: false, + x_align: St.Align.END, + y_align: St.Align.START }); + + this._messageBox = new St.BoxLayout({ style_class: 'prompt-dialog-message-layout', + vertical: true }); + mainContentBox.add(this._messageBox, + { y_align: St.Align.START, expand: true, x_fill: true, y_fill: true }); + + let subject = new St.Label({ style_class: 'prompt-dialog-headline' }); + this.prompt.bind_property('message', subject, 'text', GObject.BindingFlags.SYNC_CREATE); + + this._messageBox.add(subject, + { y_fill: false, + y_align: St.Align.START }); + + let description = new St.Label({ style_class: 'prompt-dialog-description' }); + description.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + description.clutter_text.line_wrap = true; + this.prompt.bind_property('description', description, 'text', GObject.BindingFlags.SYNC_CREATE); + this._messageBox.add(description, + { y_fill: true, + y_align: St.Align.START }); + + this._controlTable = null; + + let buttons = [{ label: '', + action: Lang.bind(this, this._onCancelButton), + key: Clutter.Escape + }, + { label: '', + action: Lang.bind(this, this._onContinueButton), + default: true + }] + + this.setButtons(buttons); + this._cancelButton = buttons[0].button; + this._continueButton = buttons[1].button; + + this.prompt.bind_property('cancel-label', this._cancelButton, 'label', GObject.BindingFlags.SYNC_CREATE); + this.prompt.bind_property('continue-label', this._continueButton, 'label', GObject.BindingFlags.SYNC_CREATE); + }, + + _buildControlTable: function() { + let table = new St.Table({ style_class: 'keyring-dialog-control-table' }); + let row = 0; + + if (this.prompt.password_visible) { + let label = new St.Label(({ style_class: 'prompt-dialog-password-label' })); + label.set_text(_("Password:")); + table.add(label, { row: row, col: 0, + x_expand: false, x_fill: true, + x_align: St.Align.START, + y_fill: false, y_align: St.Align.MIDDLE }); + this._passwordEntry = new St.Entry({ style_class: 'prompt-dialog-password-entry', + text: '', + can_focus: true}); + this._passwordEntry.clutter_text.set_password_char('\u25cf'); // ● U+25CF BLACK CIRCLE + ShellEntry.addContextMenu(this._passwordEntry, { isPassword: true }); + this._passwordEntry.clutter_text.connect('activate', Lang.bind(this, this._onPasswordActivate)); + table.add(this._passwordEntry, { row: row, col: 1, x_expand: true, x_fill: true, x_align: St.Align.START }); + row++; + } else { + this._passwordEntry = null; + } + + if (this.prompt.confirm_visible) { + var label = new St.Label(({ style_class: 'prompt-dialog-password-label' })); + label.set_text(_("Type again:")); + table.add(label, { row: row, col: 0, + x_expand: false, x_fill: true, + x_align: St.Align.START, + y_fill: false, y_align: St.Align.MIDDLE }); + this._confirmEntry = new St.Entry({ style_class: 'prompt-dialog-password-entry', + text: '', + can_focus: true}); + this._confirmEntry.clutter_text.set_password_char('\u25cf'); // ● U+25CF BLACK CIRCLE + ShellEntry.addContextMenu(this._confirmEntry, { isPassword: true }); + this._confirmEntry.clutter_text.connect('activate', Lang.bind(this, this._onConfirmActivate)); + table.add(this._confirmEntry, { row: row, col: 1, x_expand: true, x_fill: true, x_align: St.Align.START }); + row++; + } else { + this._confirmEntry = null; + } + + this.prompt.set_password_actor(this._passwordEntry ? this._passwordEntry.clutter_text : null); + this.prompt.set_confirm_actor(this._confirmEntry ? this._confirmEntry.clutter_text : null); + + if (this.prompt.choice_visible) { + let choice = new CheckBox.CheckBox(); + this.prompt.bind_property('choice-label', choice.getLabelActor(), 'text', GObject.BindingFlags.SYNC_CREATE); + this.prompt.bind_property('choice-chosen', choice.actor, 'checked', GObject.BindingFlags.SYNC_CREATE | GObject.BindingFlags.BIDIRECTIONAL); + table.add(choice.actor, { row: row, col: 1, x_expand: false, x_fill: true, x_align: St.Align.START }); + row++; + } + + let warning = new St.Label({ style_class: 'prompt-dialog-error-label' }); + warning.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + warning.clutter_text.line_wrap = true; + table.add(warning, { row: row, col: 1, x_expand: false, x_fill: false, x_align: St.Align.START }); + this.prompt.bind_property('warning-visible', warning, 'visible', GObject.BindingFlags.SYNC_CREATE); + this.prompt.bind_property('warning', warning, 'text', GObject.BindingFlags.SYNC_CREATE); + + if (this._controlTable) { + this._controlTable.destroy_all_children(); + this._controlTable.destroy(); + } + + this._controlTable = table; + this._messageBox.add(table, { x_fill: true, y_fill: true }); + }, + + _ensureOpen: function() { + // NOTE: ModalDialog.open() is safe to call if the dialog is + // already open - it just returns true without side-effects + if (this.open()) + return true; + + // The above fail if e.g. unable to get input grab + // + // In an ideal world this wouldn't happen (because the + // Shell is in complete control of the session) but that's + // just not how things work right now. + + log('keyringPrompt: Failed to show modal dialog.' + + ' Dismissing prompt request'); + this.prompt.cancel() + return false; + }, + + _onShowPassword: function(prompt) { + this._buildControlTable(); + this._ensureOpen(); + this._passwordEntry.grab_key_focus(); + }, + + _onShowConfirm: function(prompt) { + this._buildControlTable(); + this._ensureOpen(); + this._continueButton.grab_key_focus(); + }, + + _onHidePrompt: function(prompt) { + this.close(); + }, + + _onPasswordActivate: function() { + if (this.prompt.confirm_visible) + this._confirmEntry.grab_key_focus(); + else + this._onContinueButton(); + }, + + _onConfirmActivate: function() { + this._onContinueButton(); + }, + + _onContinueButton: function() { + this.prompt.complete(); + }, + + _onCancelButton: function() { + this.prompt.cancel(); + }, +}); + +const KeyringPrompter = new Lang.Class({ + Name: 'KeyringPrompter', + + _init: function() { + this._prompter = new Gcr.SystemPrompter(); + this._prompter.connect('new-prompt', function(prompter) { + let dialog = new KeyringDialog(); + return dialog.prompt; + }); + this._dbusId = null; + }, + + enable: function() { + this._prompter.register(Gio.DBus.session); + this._dbusId = Gio.DBus.session.own_name('org.gnome.keyring.SystemPrompter', + Gio.BusNameOwnerFlags.REPLACE, null, null); + }, + + disable: function() { + Gio.DBus.session.unown_name(this._dbusId); + } +}); + +const Component = KeyringPrompter; diff --git a/js/ui/components/networkAgent.js b/js/ui/components/networkAgent.js new file mode 100644 index 000000000..2eaca0c51 --- /dev/null +++ b/js/ui/components/networkAgent.js @@ -0,0 +1,694 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- + +const Clutter = imports.gi.Clutter; +const Gio = imports.gi.Gio; +const GLib = imports.gi.GLib; +const GObject = imports.gi.GObject; +const Lang = imports.lang; +const NetworkManager = imports.gi.NetworkManager; +const NMClient = imports.gi.NMClient; +const Pango = imports.gi.Pango; +const Shell = imports.gi.Shell; +const St = imports.gi.St; + +const Config = imports.misc.config; +const ModalDialog = imports.ui.modalDialog; +const PopupMenu = imports.ui.popupMenu; +const ShellEntry = imports.ui.shellEntry; + +const VPN_UI_GROUP = 'VPN Plugin UI'; + +const NetworkSecretDialog = new Lang.Class({ + Name: 'NetworkSecretDialog', + Extends: ModalDialog.ModalDialog, + + _init: function(agent, requestId, connection, settingName, hints, contentOverride) { + this.parent({ styleClass: 'prompt-dialog' }); + + this._agent = agent; + this._requestId = requestId; + this._connection = connection; + this._settingName = settingName; + this._hints = hints; + + if (contentOverride) + this._content = contentOverride; + else + this._content = this._getContent(); + + let mainContentBox = new St.BoxLayout({ style_class: 'prompt-dialog-main-layout', + vertical: false }); + this.contentLayout.add(mainContentBox, + { x_fill: true, + y_fill: true }); + + let icon = new St.Icon({ icon_name: 'dialog-password-symbolic' }); + mainContentBox.add(icon, + { x_fill: true, + y_fill: false, + x_align: St.Align.END, + y_align: St.Align.START }); + + let messageBox = new St.BoxLayout({ style_class: 'prompt-dialog-message-layout', + vertical: true }); + mainContentBox.add(messageBox, + { y_align: St.Align.START }); + + let subjectLabel = new St.Label({ style_class: 'prompt-dialog-headline', + text: this._content.title }); + messageBox.add(subjectLabel, + { y_fill: false, + y_align: St.Align.START }); + + if (this._content.message != null) { + let descriptionLabel = new St.Label({ style_class: 'prompt-dialog-description', + text: this._content.message, + // HACK: for reasons unknown to me, the label + // is not asked the correct height for width, + // and thus is underallocated + // place a fixed height to avoid overflowing + style: 'height: 3em' + }); + descriptionLabel.clutter_text.line_wrap = true; + + messageBox.add(descriptionLabel, + { y_fill: true, + y_align: St.Align.START, + expand: true }); + } + + let secretTable = new St.Table({ style_class: 'network-dialog-secret-table' }); + let initialFocusSet = false; + let pos = 0; + for (let i = 0; i < this._content.secrets.length; i++) { + let secret = this._content.secrets[i]; + let label = new St.Label({ style_class: 'prompt-dialog-password-label', + text: secret.label }); + + let reactive = secret.key != null; + + secret.entry = new St.Entry({ style_class: 'prompt-dialog-password-entry', + text: secret.value, can_focus: reactive, + reactive: reactive }); + ShellEntry.addContextMenu(secret.entry, + { isPassword: secret.password }); + + if (secret.validate) + secret.valid = secret.validate(secret); + else // no special validation, just ensure it's not empty + secret.valid = secret.value.length > 0; + + if (reactive) { + if (!initialFocusSet) { + this.setInitialKeyFocus(secret.entry); + initialFocusSet = true; + } + + secret.entry.clutter_text.connect('activate', Lang.bind(this, this._onOk)); + secret.entry.clutter_text.connect('text-changed', Lang.bind(this, function() { + secret.value = secret.entry.get_text(); + if (secret.validate) + secret.valid = secret.validate(secret); + else + secret.valid = secret.value.length > 0; + this._updateOkButton(); + })); + } else + secret.valid = true; + + secretTable.add(label, { row: pos, col: 0, + x_expand: false, x_fill: true, + x_align: St.Align.START, + y_fill: false, y_align: St.Align.MIDDLE }); + secretTable.add(secret.entry, { row: pos, col: 1, x_expand: true, x_fill: true, y_align: St.Align.END }); + pos++; + + if (secret.password) + secret.entry.clutter_text.set_password_char('\u25cf'); + } + + messageBox.add(secretTable); + + this._okButton = { label: _("Connect"), + action: Lang.bind(this, this._onOk), + default: true + }; + + this.setButtons([{ label: _("Cancel"), + action: Lang.bind(this, this.cancel), + key: Clutter.KEY_Escape, + }, + this._okButton]); + }, + + _updateOkButton: function() { + let valid = true; + for (let i = 0; i < this._content.secrets.length; i++) { + let secret = this._content.secrets[i]; + valid = valid && secret.valid; + } + + this._okButton.button.reactive = valid; + this._okButton.button.can_focus = valid; + }, + + _onOk: function() { + let valid = true; + for (let i = 0; i < this._content.secrets.length; i++) { + let secret = this._content.secrets[i]; + valid = valid && secret.valid; + if (secret.key != null) + this._agent.set_password(this._requestId, secret.key, secret.value); + } + + if (valid) { + this._agent.respond(this._requestId, Shell.NetworkAgentResponse.CONFIRMED); + this.close(global.get_current_time()); + } + // do nothing if not valid + }, + + cancel: function() { + this._agent.respond(this._requestId, Shell.NetworkAgentResponse.USER_CANCELED); + this.close(global.get_current_time()); + }, + + _validateWpaPsk: function(secret) { + let value = secret.value; + if (value.length == 64) { + // must be composed of hexadecimal digits only + for (let i = 0; i < 64; i++) { + if (!((value[i] >= 'a' && value[i] <= 'f') + || (value[i] >= 'A' && value[i] <= 'F') + || (value[i] >= '0' && value[i] <= '9'))) + return false; + } + return true; + } + + return (value.length >= 8 && value.length <= 63); + }, + + _validateStaticWep: function(secret) { + let value = secret.value; + if (secret.wep_key_type == NetworkManager.WepKeyType.KEY) { + if (value.length == 10 || value.length == 26) { + for (let i = 0; i < value.length; i++) { + if (!((value[i] >= 'a' && value[i] <= 'f') + || (value[i] >= 'A' && value[i] <= 'F') + || (value[i] >= '0' && value[i] <= '9'))) + return false; + } + } else if (value.length == 5 || value.length == 13) { + for (let i = 0; i < value.length; i++) { + if (!((value[i] >= 'a' && value[i] <= 'z') + || (value[i] >= 'A' && value[i] <= 'Z'))) + return false; + } + } else + return false; + } else if (secret.wep_key_type == NetworkManager.WepKeyType.PASSPHRASE) { + if (value.length < 0 || value.length > 64) + return false; + } + return true; + }, + + _getWirelessSecrets: function(secrets, wirelessSetting) { + let wirelessSecuritySetting = this._connection.get_setting_wireless_security(); + switch (wirelessSecuritySetting.key_mgmt) { + // First the easy ones + case 'wpa-none': + case 'wpa-psk': + secrets.push({ label: _("Password: "), key: 'psk', + value: wirelessSecuritySetting.psk || '', + validate: this._validateWpaPsk, password: true }); + break; + case 'none': // static WEP + secrets.push({ label: _("Key: "), key: 'wep-key' + wirelessSecuritySetting.wep_tx_keyidx, + value: wirelessSecuritySetting.get_wep_key(wirelessSecuritySetting.wep_tx_keyidx) || '', + wep_key_type: wirelessSecuritySetting.wep_key_type, + validate: this._validateStaticWep, password: true }); + break; + case 'ieee8021x': + if (wirelessSecuritySetting.auth_alg == 'leap') // Cisco LEAP + secrets.push({ label: _("Password: "), key: 'leap-password', + value: wirelessSecuritySetting.leap_password || '', password: true }); + else // Dynamic (IEEE 802.1x) WEP + this._get8021xSecrets(secrets); + break; + case 'wpa-eap': + this._get8021xSecrets(secrets); + break; + default: + log('Invalid wireless key management: ' + wirelessSecuritySetting.key_mgmt); + } + }, + + _get8021xSecrets: function(secrets) { + let ieee8021xSetting = this._connection.get_setting_802_1x(); + let phase2method; + + switch (ieee8021xSetting.get_eap_method(0)) { + case 'md5': + case 'leap': + case 'ttls': + case 'peap': + // TTLS and PEAP are actually much more complicated, but this complication + // is not visible here since we only care about phase2 authentication + // (and don't even care of which one) + secrets.push({ label: _("Username: "), key: null, + value: ieee8021xSetting.identity || '', password: false }); + secrets.push({ label: _("Password: "), key: 'password', + value: ieee8021xSetting.password || '', password: true }); + break; + case 'tls': + secrets.push({ label: _("Identity: "), key: null, + value: ieee8021xSetting.identity || '', password: false }); + secrets.push({ label: _("Private key password: "), key: 'private-key-password', + value: ieee8021xSetting.private_key_password || '', password: true }); + break; + default: + log('Invalid EAP/IEEE802.1x method: ' + ieee8021xSetting.get_eap_method(0)); + } + }, + + _getPPPoESecrets: function(secrets) { + let pppoeSetting = this._connection.get_setting_pppoe(); + secrets.push({ label: _("Username: "), key: 'username', + value: pppoeSetting.username || '', password: false }); + secrets.push({ label: _("Service: "), key: 'service', + value: pppoeSetting.service || '', password: false }); + secrets.push({ label: _("Password: "), key: 'password', + value: pppoeSetting.password || '', password: true }); + }, + + _getMobileSecrets: function(secrets, connectionType) { + let setting; + if (connectionType == 'bluetooth') + setting = this._connection.get_setting_cdma() || this._connection.get_setting_gsm(); + else + setting = this._connection.get_setting_by_name(connectionType); + secrets.push({ label: _("Password: "), key: 'password', + value: setting.value || '', password: true }); + }, + + _getContent: function() { + let connectionSetting = this._connection.get_setting_connection(); + let connectionType = connectionSetting.get_connection_type(); + let wirelessSetting; + let ssid; + + let content = { }; + content.secrets = [ ]; + + switch (connectionType) { + case '802-11-wireless': + wirelessSetting = this._connection.get_setting_wireless(); + ssid = NetworkManager.utils_ssid_to_utf8(wirelessSetting.get_ssid()); + content.title = _("Authentication required by wireless network"); + content.message = _("Passwords or encryption keys are required to access the wireless network '%s'.").format(ssid); + this._getWirelessSecrets(content.secrets, wirelessSetting); + break; + case '802-3-ethernet': + content.title = _("Wired 802.1X authentication"); + content.message = null; + content.secrets.push({ label: _("Network name: "), key: null, + value: connectionSetting.get_id(), password: false }); + this._get8021xSecrets(content.secrets); + break; + case 'pppoe': + content.title = _("DSL authentication"); + content.message = null; + this._getPPPoESecrets(content.secrets); + break; + case 'gsm': + if (this._hints.indexOf('pin') != -1) { + let gsmSetting = this._connection.get_setting_gsm(); + content.title = _("PIN code required"); + content.message = _("PIN code is needed for the mobile broadband device"); + content.secrets.push({ label: _("PIN: "), key: 'pin', + value: gsmSetting.pin || '', password: true }); + } + // fall through + case 'cdma': + case 'bluetooth': + content.title = _("Mobile broadband network password"); + content.message = _("A password is required to connect to '%s'.").format(connectionSetting.get_id()); + this._getMobileSecrets(content.secrets, connectionType); + break; + default: + log('Invalid connection type: ' + connectionType); + }; + + return content; + } +}); + +const VPNRequestHandler = new Lang.Class({ + Name: 'VPNRequestHandler', + + _init: function(agent, requestId, authHelper, serviceType, connection, hints, flags) { + this._agent = agent; + this._requestId = requestId; + this._connection = connection; + this._pluginOutBuffer = []; + this._title = null; + this._description = null; + this._content = [ ]; + this._shellDialog = null; + + let connectionSetting = connection.get_setting_connection(); + + let argv = [ authHelper.fileName, + '-u', connectionSetting.uuid, + '-n', connectionSetting.id, + '-s', serviceType + ]; + if (authHelper.externalUIMode) + argv.push('--external-ui-mode'); + if (flags & NMClient.SecretAgentGetSecretsFlags.ALLOW_INTERACTION) + argv.push('-i'); + if (flags & NMClient.SecretAgentGetSecretsFlags.REQUEST_NEW) + argv.push('-r'); + + this._newStylePlugin = authHelper.externalUIMode; + + try { + let [success, pid, stdin, stdout, stderr] = + GLib.spawn_async_with_pipes(null, /* pwd */ + argv, + null, /* envp */ + GLib.SpawnFlags.DO_NOT_REAP_CHILD, + null /* child_setup */); + + this._childPid = pid; + this._stdin = new Gio.UnixOutputStream({ fd: stdin, close_fd: true }); + this._stdout = new Gio.UnixInputStream({ fd: stdout, close_fd: true }); + // We need this one too, even if don't actually care of what the process + // has to say on stderr, because otherwise the fd opened by g_spawn_async_with_pipes + // is kept open indefinitely + let stderrStream = new Gio.UnixInputStream({ fd: stderr, close_fd: true }); + stderrStream.close(null); + this._dataStdout = new Gio.DataInputStream({ base_stream: this._stdout }); + + if (this._newStylePlugin) + this._readStdoutNewStyle(); + else + this._readStdoutOldStyle(); + + this._childWatch = GLib.child_watch_add(GLib.PRIORITY_DEFAULT, pid, + Lang.bind(this, this._vpnChildFinished)); + + this._writeConnection(); + } catch(e) { + logError(e, 'error while spawning VPN auth helper'); + + this._agent.respond(requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR); + } + }, + + cancel: function() { + if (this._newStylePlugin && this._shellDialog) { + this._shellDialog.close(global.get_current_time()); + this._shellDialog.destroy(); + } else { + try { + this._stdin.write('QUIT\n\n', null); + } catch(e) { /* ignore broken pipe errors */ } + } + + this.destroy(); + }, + + destroy: function() { + if (this._destroyed) + return; + + GLib.source_remove(this._childWatch); + + this._stdin.close(null); + // Stdout is closed when we finish reading from it + + this._destroyed = true; + }, + + _vpnChildFinished: function(pid, status, requestObj) { + if (this._newStylePlugin) { + // For new style plugin, all work is done in the async reading functions + // Just reap the process here + return; + } + + let [exited, exitStatus] = Shell.util_wifexited(status); + + if (exited) { + if (exitStatus != 0) + this._agent.respond(this._requestId, Shell.NetworkAgentResponse.USER_CANCELED); + else + this._agent.respond(this._requestId, Shell.NetworkAgentResponse.CONFIRMED); + } else + this._agent.respond(this._requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR); + + this.destroy(); + }, + + _vpnChildProcessLineOldStyle: function(line) { + if (this._previousLine != undefined) { + // Two consecutive newlines mean that the child should be closed + // (the actual newlines are eaten by Gio.DataInputStream) + // Send a termination message + if (line == '' && this._previousLine == '') { + try { + this._stdin.write('QUIT\n\n', null); + } catch(e) { /* ignore broken pipe errors */ } + } else { + this._agent.set_password(this._requestId, this._previousLine, line); + this._previousLine = undefined; + } + } else { + this._previousLine = line; + } + }, + + _readStdoutOldStyle: function() { + this._dataStdout.read_line_async(GLib.PRIORITY_DEFAULT, null, Lang.bind(this, function(stream, result) { + let [line, len] = this._dataStdout.read_line_finish_utf8(result); + + if (line == null) { + // end of file + this._stdout.close(null); + return; + } + + this._vpnChildProcessLineOldStyle(line); + + // try to read more! + this._readStdoutOldStyle(); + })); + }, + + _readStdoutNewStyle: function() { + this._dataStdout.fill_async(-1, GLib.PRIORITY_DEFAULT, null, Lang.bind(this, function(stream, result) { + let cnt = this._dataStdout.fill_finish(result); + + if (cnt == 0) { + // end of file + this._showNewStyleDialog(); + + this._stdout.close(null); + return; + } + + // Try to read more + this._dataStdout.set_buffer_size(2 * this._dataStdout.get_buffer_size()); + this._readStdoutNewStyle(); + })); + }, + + _showNewStyleDialog: function() { + let keyfile = new GLib.KeyFile(); + let contentOverride; + + try { + let data = this._dataStdout.peek_buffer(); + keyfile.load_from_data(data.toString(), data.length, + GLib.KeyFileFlags.NONE); + + if (keyfile.get_integer(VPN_UI_GROUP, 'Version') != 2) + throw new Error('Invalid plugin keyfile version, is %d'); + + contentOverride = { title: keyfile.get_string(VPN_UI_GROUP, 'Title'), + message: keyfile.get_string(VPN_UI_GROUP, 'Description'), + secrets: [] }; + + let [groups, len] = keyfile.get_groups(); + for (let i = 0; i < groups.length; i++) { + if (groups[i] == VPN_UI_GROUP) + continue; + + let value = keyfile.get_string(groups[i], 'Value'); + let shouldAsk = keyfile.get_boolean(groups[i], 'ShouldAsk'); + + if (shouldAsk) { + contentOverride.secrets.push({ label: keyfile.get_string(groups[i], 'Label'), + key: groups[i], + value: value, + password: keyfile.get_boolean(groups[i], 'IsSecret') + }); + } else { + if (!value.length) // Ignore empty secrets + continue; + + this._agent.set_password(this._requestId, groups[i], value); + } + } + } catch(e) { + logError(e, 'error while reading VPN plugin output keyfile'); + + this._agent.respond(this._requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR); + return; + } + + if (contentOverride.secrets.length) { + // Only show the dialog if we actually have something to ask + this._shellDialog = new NetworkSecretDialog(this._agent, this._requestId, this._connection, 'vpn', [], contentOverride); + this._shellDialog.open(global.get_current_time()); + } else { + this._agent.respond(this._requestId, Shell.NetworkAgentResponse.CONFIRMED); + } + }, + + _writeConnection: function() { + let vpnSetting = this._connection.get_setting_vpn(); + + try { + vpnSetting.foreach_data_item(Lang.bind(this, function(key, value) { + this._stdin.write('DATA_KEY=' + key + '\n', null); + this._stdin.write('DATA_VAL=' + (value || '') + '\n\n', null); + })); + vpnSetting.foreach_secret(Lang.bind(this, function(key, value) { + this._stdin.write('SECRET_KEY=' + key + '\n', null); + this._stdin.write('SECRET_VAL=' + (value || '') + '\n\n', null); + })); + this._stdin.write('DONE\n\n', null); + } catch(e) { + logError(e, 'internal error while writing connection to helper'); + + this._agent.respond(this._requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR); + } + }, +}); + +const NetworkAgent = new Lang.Class({ + Name: 'NetworkAgent', + + _init: function() { + this._native = new Shell.NetworkAgent({ auto_register: false, + identifier: 'org.gnome.Shell.NetworkAgent' }); + + this._dialogs = { }; + this._vpnRequests = { }; + + this._native.connect('new-request', Lang.bind(this, this._newRequest)); + this._native.connect('cancel-request', Lang.bind(this, this._cancelRequest)); + }, + + enable: function() { + this._native.register(); + }, + + disable: function() { + this._native.unregister(); + }, + + _newRequest: function(agent, requestId, connection, settingName, hints, flags) { + if (settingName == 'vpn') { + this._vpnRequest(requestId, connection, hints, flags); + return; + } + + let dialog = new NetworkSecretDialog(agent, requestId, connection, settingName, hints); + dialog.connect('destroy', Lang.bind(this, function() { + delete this._dialogs[requestId]; + })); + this._dialogs[requestId] = dialog; + dialog.open(global.get_current_time()); + }, + + _cancelRequest: function(agent, requestId) { + if (this._dialogs[requestId]) { + this._dialogs[requestId].close(global.get_current_time()); + this._dialogs[requestId].destroy(); + delete this._dialogs[requestId]; + } else if (this._vpnRequests[requestId]) { + this._vpnRequests[requestId].cancel(); + delete this._vpnRequests[requestId]; + } + }, + + _vpnRequest: function(requestId, connection, hints, flags) { + let vpnSetting = connection.get_setting_vpn(); + let serviceType = vpnSetting.service_type; + + this._buildVPNServiceCache(); + + let binary = this._vpnBinaries[serviceType]; + if (!binary) { + log('Invalid VPN service type (cannot find authentication binary)'); + + /* cancel the auth process */ + this._native.respond(requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR); + return; + } + + this._vpnRequests[requestId] = new VPNRequestHandler(this._native, requestId, binary, serviceType, connection, hints, flags); + }, + + _buildVPNServiceCache: function() { + if (this._vpnCacheBuilt) + return; + + this._vpnCacheBuilt = true; + this._vpnBinaries = { }; + + let dir = Gio.file_new_for_path(GLib.build_filenamev([Config.SYSCONFDIR, 'NetworkManager/VPN'])); + try { + let fileEnum = dir.enumerate_children('standard::name', Gio.FileQueryInfoFlags.NONE, null); + let info; + + while ((info = fileEnum.next_file(null))) { + let name = info.get_name(); + if (name.substr(-5) != '.name') + continue; + + try { + let keyfile = new GLib.KeyFile(); + keyfile.load_from_file(dir.get_child(name).get_path(), GLib.KeyFileFlags.NONE); + let service = keyfile.get_string('VPN Connection', 'service'); + let binary = keyfile.get_string('GNOME', 'auth-dialog'); + let externalUIMode = false; + try { + externalUIMode = keyfile.get_boolean('GNOME', 'supports-external-ui-mode'); + } catch(e) { } // ignore errors if key does not exist + let path = binary; + if (!GLib.path_is_absolute(path)) { + path = GLib.build_filenamev([Config.LIBEXECDIR, path]); + } + + if (GLib.file_test(path, GLib.FileTest.IS_EXECUTABLE)) + this._vpnBinaries[service] = { fileName: path, externalUIMode: externalUIMode }; + else + throw new Error('VPN plugin at %s is not executable'.format(path)); + } catch(e) { + log('Error \'%s\' while processing VPN keyfile \'%s\''. + format(e.message, dir.get_child(name).get_path())); + continue; + } + } + } catch(e) { + logError(e, 'error while enumerating VPN auth helpers'); + } + } +}); +const Component = NetworkAgent; diff --git a/js/ui/components/polkitAgent.js b/js/ui/components/polkitAgent.js new file mode 100644 index 000000000..0fa9ba449 --- /dev/null +++ b/js/ui/components/polkitAgent.js @@ -0,0 +1,391 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- + +const Lang = imports.lang; +const Signals = imports.signals; +const Shell = imports.gi.Shell; +const AccountsService = imports.gi.AccountsService; +const Clutter = imports.gi.Clutter; +const St = imports.gi.St; +const Pango = imports.gi.Pango; +const GLib = imports.gi.GLib; +const Gio = imports.gi.Gio; +const Mainloop = imports.mainloop; +const Polkit = imports.gi.Polkit; +const PolkitAgent = imports.gi.PolkitAgent; + +const Components = imports.ui.components; +const ModalDialog = imports.ui.modalDialog; +const ShellEntry = imports.ui.shellEntry; +const UserMenu = imports.ui.userMenu; + +const DIALOG_ICON_SIZE = 48; + +const AuthenticationDialog = new Lang.Class({ + Name: 'AuthenticationDialog', + Extends: ModalDialog.ModalDialog, + + _init: function(actionId, message, cookie, userNames) { + this.parent({ styleClass: 'prompt-dialog' }); + + this.actionId = actionId; + this.message = message; + this.userNames = userNames; + this._wasDismissed = false; + this._completed = false; + + let mainContentBox = new St.BoxLayout({ style_class: 'prompt-dialog-main-layout', + vertical: false }); + this.contentLayout.add(mainContentBox, + { x_fill: true, + y_fill: true }); + + let icon = new St.Icon({ icon_name: 'dialog-password-symbolic' }); + mainContentBox.add(icon, + { x_fill: true, + y_fill: false, + x_align: St.Align.END, + y_align: St.Align.START }); + + let messageBox = new St.BoxLayout({ style_class: 'prompt-dialog-message-layout', + vertical: true }); + mainContentBox.add(messageBox, + { y_align: St.Align.START }); + + this._subjectLabel = new St.Label({ style_class: 'prompt-dialog-headline', + text: _("Authentication Required") }); + + messageBox.add(this._subjectLabel, + { y_fill: false, + y_align: St.Align.START }); + + this._descriptionLabel = new St.Label({ style_class: 'prompt-dialog-description', + text: message }); + this._descriptionLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + this._descriptionLabel.clutter_text.line_wrap = true; + + messageBox.add(this._descriptionLabel, + { y_fill: true, + y_align: St.Align.START }); + + if (userNames.length > 1) { + log('polkitAuthenticationAgent: Received ' + userNames.length + + ' identities that can be used for authentication. Only ' + + 'considering one.'); + } + + let userName = GLib.get_user_name(); + if (userNames.indexOf(userName) < 0) + userName = 'root'; + if (userNames.indexOf(userName) < 0) + userName = userNames[0]; + + this._user = AccountsService.UserManager.get_default().get_user(userName); + let userRealName = this._user.get_real_name() + this._userLoadedId = this._user.connect('notify::is_loaded', + Lang.bind(this, this._onUserChanged)); + this._userChangedId = this._user.connect('changed', + Lang.bind(this, this._onUserChanged)); + + // Special case 'root' + let userIsRoot = false; + if (userName == 'root') { + userIsRoot = true; + userRealName = _("Administrator"); + } + + if (userIsRoot) { + let userLabel = new St.Label(({ style_class: 'polkit-dialog-user-root-label', + text: userRealName })); + messageBox.add(userLabel); + } else { + let userBox = new St.BoxLayout({ style_class: 'polkit-dialog-user-layout', + vertical: false }); + messageBox.add(userBox); + this._userAvatar = new UserMenu.UserAvatarWidget(this._user, + { iconSize: DIALOG_ICON_SIZE, + styleClass: 'polkit-dialog-user-icon' }); + this._userAvatar.actor.hide(); + userBox.add(this._userAvatar.actor, + { x_fill: true, + y_fill: false, + x_align: St.Align.END, + y_align: St.Align.START }); + let userLabel = new St.Label(({ style_class: 'polkit-dialog-user-label', + text: userRealName })); + userBox.add(userLabel, + { x_fill: true, + y_fill: false, + x_align: St.Align.END, + y_align: St.Align.MIDDLE }); + } + + this._onUserChanged(); + + this._passwordBox = new St.BoxLayout({ vertical: false, style_class: 'prompt-dialog-password-box' }); + messageBox.add(this._passwordBox); + this._passwordLabel = new St.Label(({ style_class: 'prompt-dialog-password-label' })); + this._passwordBox.add(this._passwordLabel, { y_fill: false, y_align: St.Align.MIDDLE }); + this._passwordEntry = new St.Entry({ style_class: 'prompt-dialog-password-entry', + text: "", + can_focus: true}); + ShellEntry.addContextMenu(this._passwordEntry, { isPassword: true }); + this._passwordEntry.clutter_text.connect('activate', Lang.bind(this, this._onEntryActivate)); + this._passwordBox.add(this._passwordEntry, + { expand: true }); + this.setInitialKeyFocus(this._passwordEntry); + this._passwordBox.hide(); + + this._errorMessageLabel = new St.Label({ style_class: 'prompt-dialog-error-label' }); + this._errorMessageLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + this._errorMessageLabel.clutter_text.line_wrap = true; + messageBox.add(this._errorMessageLabel); + this._errorMessageLabel.hide(); + + this._infoMessageLabel = new St.Label({ style_class: 'prompt-dialog-info-label' }); + this._infoMessageLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + this._infoMessageLabel.clutter_text.line_wrap = true; + messageBox.add(this._infoMessageLabel); + this._infoMessageLabel.hide(); + + /* text is intentionally non-blank otherwise the height is not the same as for + * infoMessage and errorMessageLabel - but it is still invisible because + * gnome-shell.css sets the color to be transparent + */ + this._nullMessageLabel = new St.Label({ style_class: 'prompt-dialog-null-label', + text: 'abc'}); + this._nullMessageLabel.add_style_class_name('hidden'); + this._nullMessageLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; + this._nullMessageLabel.clutter_text.line_wrap = true; + messageBox.add(this._nullMessageLabel); + this._nullMessageLabel.show(); + + this.setButtons([{ label: _("Cancel"), + action: Lang.bind(this, this.cancel), + key: Clutter.Escape + }, + { label: _("Authenticate"), + action: Lang.bind(this, this._onAuthenticateButtonPressed), + default: true + }]); + + this._doneEmitted = false; + + this._identityToAuth = Polkit.UnixUser.new_for_name(userName); + this._cookie = cookie; + + this._session = new PolkitAgent.Session({ identity: this._identityToAuth, + cookie: this._cookie }); + this._session.connect('completed', Lang.bind(this, this._onSessionCompleted)); + this._session.connect('request', Lang.bind(this, this._onSessionRequest)); + this._session.connect('show-error', Lang.bind(this, this._onSessionShowError)); + this._session.connect('show-info', Lang.bind(this, this._onSessionShowInfo)); + }, + + startAuthentication: function() { + this._session.initiate(); + }, + + _ensureOpen: function() { + // NOTE: ModalDialog.open() is safe to call if the dialog is + // already open - it just returns true without side-effects + if (!this.open(global.get_current_time())) { + // This can fail if e.g. unable to get input grab + // + // In an ideal world this wouldn't happen (because the + // Shell is in complete control of the session) but that's + // just not how things work right now. + // + // One way to make this happen is by running 'sleep 3; + // pkexec bash' and then opening a popup menu. + // + // We could add retrying if this turns out to be a problem + + log('polkitAuthenticationAgent: Failed to show modal dialog.' + + ' Dismissing authentication request for action-id ' + this.actionId + + ' cookie ' + this._cookie); + this._emitDone(false, true); + } + }, + + _emitDone: function(keepVisible, dismissed) { + if (!this._doneEmitted) { + this._doneEmitted = true; + this.emit('done', keepVisible, dismissed); + } + }, + + _onEntryActivate: function() { + let response = this._passwordEntry.get_text(); + this._session.response(response); + // When the user responds, dismiss already shown info and + // error texts (if any) + this._errorMessageLabel.hide(); + this._infoMessageLabel.hide(); + this._nullMessageLabel.show(); + }, + + _onAuthenticateButtonPressed: function() { + this._onEntryActivate(); + }, + + _onSessionCompleted: function(session, gainedAuthorization) { + if (this._completed) + return; + + this._completed = true; + + if (!gainedAuthorization) { + /* Unless we are showing an existing error message from the PAM + * module (the PAM module could be reporting the authentication + * error providing authentication-method specific information), + * show "Sorry, that didn't work. Please try again." + */ + if (!this._errorMessageLabel.visible && !this._wasDismissed) { + /* Translators: "that didn't work" refers to the fact that the + * requested authentication was not gained; this can happen + * because of an authentication error (like invalid password), + * for instance. */ + this._errorMessageLabel.set_text(_("Sorry, that didn\'t work. Please try again.")); + this._errorMessageLabel.show(); + this._infoMessageLabel.hide(); + this._nullMessageLabel.hide(); + } + } + this._emitDone(!gainedAuthorization, false); + }, + + _onSessionRequest: function(session, request, echo_on) { + // Cheap localization trick + if (request == 'Password:' || request == 'Password: ') + this._passwordLabel.set_text(_("Password:")); + else + this._passwordLabel.set_text(request); + + if (echo_on) + this._passwordEntry.clutter_text.set_password_char(''); + else + this._passwordEntry.clutter_text.set_password_char('\u25cf'); // ● U+25CF BLACK CIRCLE + + this._passwordBox.show(); + this._passwordEntry.set_text(''); + this._passwordEntry.grab_key_focus(); + this._ensureOpen(); + }, + + _onSessionShowError: function(session, text) { + this._passwordEntry.set_text(''); + this._errorMessageLabel.set_text(text); + this._errorMessageLabel.show(); + this._infoMessageLabel.hide(); + this._nullMessageLabel.hide(); + this._ensureOpen(); + }, + + _onSessionShowInfo: function(session, text) { + this._passwordEntry.set_text(''); + this._infoMessageLabel.set_text(text); + this._infoMessageLabel.show(); + this._errorMessageLabel.hide(); + this._nullMessageLabel.hide(); + this._ensureOpen(); + }, + + destroySession: function() { + if (this._session) { + if (!this._completed) + this._session.cancel(); + this._session = null; + } + }, + + _onUserChanged: function() { + if (this._user.is_loaded) { + this._userAvatar.update(); + this._userAvatar.actor.show(); + } + }, + + cancel: function() { + this._wasDismissed = true; + this.close(global.get_current_time()); + this._emitDone(false, true); + }, +}); +Signals.addSignalMethods(AuthenticationDialog.prototype); + +const AuthenticationAgent = new Lang.Class({ + Name: 'AuthenticationAgent', + + _init: function() { + this._currentDialog = null; + this._isCompleting = false; + this._handle = null; + this._native = new Shell.PolkitAuthenticationAgent(); + this._native.connect('initiate', Lang.bind(this, this._onInitiate)); + this._native.connect('cancel', Lang.bind(this, this._onCancel)); + }, + + enable: function() { + this._native.register(); + }, + + disable: function() { + this._native.unregister(); + }, + + _onInitiate: function(nativeAgent, actionId, message, iconName, cookie, userNames) { + this._currentDialog = new AuthenticationDialog(actionId, message, cookie, userNames); + + // We actually don't want to open the dialog until we know for + // sure that we're going to interact with the user. For + // example, if the password for the identity to auth is blank + // (which it will be on a live CD) then there will be no + // conversation at all... of course, we don't *know* that + // until we actually try it. + // + // See https://bugzilla.gnome.org/show_bug.cgi?id=643062 for more + // discussion. + + this._currentDialog.connect('done', Lang.bind(this, this._onDialogDone)); + this._currentDialog.startAuthentication(); + }, + + _onCancel: function(nativeAgent) { + this._completeRequest(false, false); + }, + + _onDialogDone: function(dialog, keepVisible, dismissed) { + this._completeRequest(keepVisible, dismissed); + }, + + _reallyCompleteRequest: function(dismissed) { + this._currentDialog.close(); + this._currentDialog.destroySession(); + this._currentDialog = null; + this._isCompleting = false; + + this._native.complete(dismissed) + }, + + _completeRequest: function(keepVisible, wasDismissed) { + if (this._isCompleting) + return; + + this._isCompleting = true; + + if (keepVisible) { + // Give the user 2 seconds to read 'Authentication Failure' before + // dismissing the dialog + Mainloop.timeout_add(2000, + Lang.bind(this, + function() { + this._reallyCompleteRequest(wasDismissed); + return false; + })); + } else { + this._reallyCompleteRequest(wasDismissed); + } + } +}); + +const Component = AuthenticationAgent; diff --git a/js/ui/components/recorder.js b/js/ui/components/recorder.js new file mode 100644 index 000000000..94c49ce40 --- /dev/null +++ b/js/ui/components/recorder.js @@ -0,0 +1,58 @@ + +const Lang = imports.lang; + +const Gio = imports.gi.Gio; +const Meta = imports.gi.Meta; +const Shell = imports.gi.Shell; + +const Recorder = new Lang.Class({ + Name: 'Recorder', + + _init: function() { + this._recorderSettings = new Gio.Settings({ schema: 'org.gnome.shell.recorder' }); + this._desktopLockdownSettings = new Gio.Settings({ schema: 'org.gnome.desktop.lockdown' }); + this._bindingSettings = new Gio.Settings({ schema: 'org.gnome.shell.keybindings' }); + this._recorder = null; + }, + + enable: function() { + global.display.add_keybinding('toggle-recording', + this._bindingSettings, + Meta.KeyBindingFlags.NONE, Lang.bind(this, this._toggleRecorder)); + }, + + disable: function() { + global.display.remove_keybinding('toggle-recording'); + }, + + _ensureRecorder: function() { + if (this._recorder == null) + this._recorder = new Shell.Recorder({ stage: global.stage }); + return this._recorder; + }, + + _toggleRecorder: function() { + let recorder = this._ensureRecorder(); + if (recorder.is_recording()) { + recorder.close(); + Meta.enable_unredirect_for_screen(global.screen); + } else if (!desktopLockdownSettings.get_boolean('disable-save-to-disk')) { + // read the parameters from GSettings always in case they have changed + recorder.set_framerate(recorderSettings.get_int('framerate')); + /* Translators: this is a filename used for screencast recording */ + // xgettext:no-c-format + recorder.set_filename(_("Screencast from %d %t") + '.' + recorderSettings.get_string('file-extension')); + let pipeline = recorderSettings.get_string('pipeline'); + + if (!pipeline.match(/^\s*$/)) + recorder.set_pipeline(pipeline); + else + recorder.set_pipeline(null); + + Meta.disable_unredirect_for_screen(global.screen); + recorder.record(); + } + } +}); + +const Component = Recorder; diff --git a/js/ui/components/telepathyClient.js b/js/ui/components/telepathyClient.js new file mode 100644 index 000000000..a6abeb193 --- /dev/null +++ b/js/ui/components/telepathyClient.js @@ -0,0 +1,1411 @@ +// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- + +const Gio = imports.gi.Gio; +const GLib = imports.gi.GLib; +const Lang = imports.lang; +const Mainloop = imports.mainloop; +const Shell = imports.gi.Shell; +const Signals = imports.signals; +const St = imports.gi.St; +const Tpl = imports.gi.TelepathyLogger; +const Tp = imports.gi.TelepathyGLib; + +const History = imports.misc.history; +const Params = imports.misc.params; +const Main = imports.ui.main; +const MessageTray = imports.ui.messageTray; + + +// See Notification.appendMessage +const SCROLLBACK_IMMEDIATE_TIME = 60; // 1 minute +const SCROLLBACK_RECENT_TIME = 15 * 60; // 15 minutes +const SCROLLBACK_RECENT_LENGTH = 20; +const SCROLLBACK_IDLE_LENGTH = 5; + +// See Source._displayPendingMessages +const SCROLLBACK_HISTORY_LINES = 10; + +// See Notification._onEntryChanged +const COMPOSING_STOP_TIMEOUT = 5; + +const NotificationDirection = { + SENT: 'chat-sent', + RECEIVED: 'chat-received' +}; + +function makeMessageFromTpMessage(tpMessage, direction) { + let [text, flags] = tpMessage.to_text(); + + let timestamp = tpMessage.get_sent_timestamp(); + if (timestamp == 0) + timestamp = tpMessage.get_received_timestamp(); + + return { + messageType: tpMessage.get_message_type(), + text: text, + sender: tpMessage.sender.alias, + timestamp: timestamp, + direction: direction + }; +} + + +function makeMessageFromTplEvent(event) { + let sent = event.get_sender().get_entity_type() == Tpl.EntityType.SELF; + let direction = sent ? NotificationDirection.SENT : NotificationDirection.RECEIVED; + + return { + messageType: event.get_message_type(), + text: event.get_message(), + sender: event.get_sender().get_alias(), + timestamp: event.get_timestamp(), + direction: direction + }; +} + +const TelepathyClient = new Lang.Class({ + Name: 'TelepathyClient', + + _init: function() { + // channel path -> ChatSource + this._chatSources = {}; + this._chatState = Tp.ChannelChatState.ACTIVE; + + // account path -> AccountNotification + this._accountNotifications = {}; + + // Define features we want + this._accountManager = Tp.AccountManager.dup(); + let factory = this._accountManager.get_factory(); + factory.add_account_features([Tp.Account.get_feature_quark_connection()]); + factory.add_connection_features([Tp.Connection.get_feature_quark_contact_list()]); + factory.add_channel_features([Tp.Channel.get_feature_quark_contacts()]); + factory.add_contact_features([Tp.ContactFeature.ALIAS, + Tp.ContactFeature.AVATAR_DATA, + Tp.ContactFeature.PRESENCE, + Tp.ContactFeature.SUBSCRIPTION_STATES]); + + // Set up a SimpleObserver, which will call _observeChannels whenever a + // channel matching its filters is detected. + // The second argument, recover, means _observeChannels will be run + // for any existing channel as well. + this._tpClient = new Shell.TpClient({ name: 'GnomeShell', + account_manager: this._accountManager, + uniquify_name: true }); + this._tpClient.set_observe_channels_func( + Lang.bind(this, this._observeChannels)); + this._tpClient.set_approve_channels_func( + Lang.bind(this, this._approveChannels)); + this._tpClient.set_handle_channels_func( + Lang.bind(this, this._handleChannels)); + + // Watch subscription requests and connection errors + this._subscriptionSource = null; + this._accountSource = null; + + // Workaround for gjs not supporting GPtrArray in signals. + // See BGO bug #653941 for context. + this._tpClient.set_contact_list_changed_func( + Lang.bind(this, this._contactListChanged)); + + // Allow other clients (such as Empathy) to pre-empt our channels if + // needed + this._tpClient.set_delegated_channels_callback( + Lang.bind(this, this._delegatedChannelsCb)); + }, + + enable: function() { + try { + this._tpClient.register(); + } catch (e) { + throw new Error('Couldn\'t register Telepathy client. Error: \n' + e); + } + + this._accountManagerValidityChangedId = this._accountManager.connect('account-validity-changed', + Lang.bind(this, this._accountValidityChanged)); + + if (!this._accountManager.is_prepared(Tp.AccountManager.get_feature_quark_core())) + this._accountManager.prepare_async(null, Lang.bind(this, this._accountManagerPrepared)); + }, + + disable: function() { + this._tpClient.unregister(); + this._accountManager.disconnect(this._accountManagerValidityChangedId); + this._accountManagerValidityChangedId = 0; + }, + + _observeChannels: function(observer, account, conn, channels, + dispatchOp, requests, context) { + let len = channels.length; + for (let i = 0; i < len; i++) { + let channel = channels[i]; + let [targetHandle, targetHandleType] = channel.get_handle(); + + if (channel.get_invalidated()) + continue; + + /* Only observe contact text channels */ + if ((!(channel instanceof Tp.TextChannel)) || + targetHandleType != Tp.HandleType.CONTACT) + continue; + + this._createChatSource(account, conn, channel, channel.get_target_contact()); + } + + context.accept(); + }, + + _createChatSource: function(account, conn, channel, contact) { + if (this._chatSources[channel.get_object_path()]) + return; + + let source = new ChatSource(account, conn, channel, contact, this._tpClient); + + this._chatSources[channel.get_object_path()] = source; + source.connect('destroy', Lang.bind(this, + function() { + if (this._tpClient.is_handling_channel(channel)) { + // The chat box has been destroyed so it can't + // handle the channel any more. + channel.close_async(function(src, result) { + channel.close_finish(result); + }); + } + + delete this._chatSources[channel.get_object_path()]; + })); + }, + + _handleChannels: function(handler, account, conn, channels, + requests, user_action_time, context) { + this._handlingChannels(account, conn, channels, true); + context.accept(); + }, + + _handlingChannels: function(account, conn, channels, notify) { + let len = channels.length; + for (let i = 0; i < len; i++) { + let channel = channels[i]; + + // We can only handle text channel, so close any other channel + if (!(channel instanceof Tp.TextChannel)) { + channel.close_async(null); + continue; + } + + if (channel.get_invalidated()) + continue; + + // 'notify' will be true when coming from an actual HandleChannels + // call, and not when from a successful Claim call. The point is + // we don't want to notify for a channel we just claimed which + // has no new messages (for example, a new channel which only has + // a delivery notification). We rely on _displayPendingMessages() + // and _messageReceived() to notify for new messages. + + // But we should still notify from HandleChannels because the + // Telepathy spec states that handlers must foreground channels + // in HandleChannels calls which are already being handled. + + if (notify && this._tpClient.is_handling_channel(channel)) { + // We are already handling the channel, display the source + let source = this._chatSources[channel.get_object_path()]; + if (source) + source.notify(); + } + } + }, + + _displayRoomInvitation: function(conn, channel, dispatchOp, context) { + // We can only approve the rooms if we have been invited to it + let selfContact = channel.group_get_self_contact(); + if (selfContact == null) { + context.fail(new Tp.Error({ code: Tp.Error.INVALID_ARGUMENT, + message: 'Not invited to the room' })); + return; + } + + let [invited, inviter, reason, msg] = channel.group_get_local_pending_contact_info(selfContact); + if (!invited) { + context.fail(new Tp.Error({ code: Tp.Error.INVALID_ARGUMENT, + message: 'Not invited to the room' })); + return; + } + + // FIXME: We don't have a 'chat room' icon (bgo #653737) use + // system-users for now as Empathy does. + let source = new ApproverSource(dispatchOp, _("Invitation"), + Gio.icon_new_for_string('system-users')); + Main.messageTray.add(source); + + let notif = new RoomInviteNotification(source, dispatchOp, channel, inviter); + source.notify(notif); + context.accept(); + }, + + _approveChannels: function(approver, account, conn, channels, + dispatchOp, context) { + let channel = channels[0]; + let chanType = channel.get_channel_type(); + + if (channel.get_invalidated()) { + context.fail(new Tp.Error({ code: Tp.Error.INVALID_ARGUMENT, + message: 'Channel is invalidated' })); + return; + } + + if (chanType == Tp.IFACE_CHANNEL_TYPE_TEXT) + this._approveTextChannel(account, conn, channel, dispatchOp, context); + else if (chanType == Tp.IFACE_CHANNEL_TYPE_CALL) + this._approveCall(account, conn, channel, dispatchOp, context); + else if (chanType == Tp.IFACE_CHANNEL_TYPE_FILE_TRANSFER) + this._approveFileTransfer(account, conn, channel, dispatchOp, context); + else + context.fail(new Tp.Error({ code: Tp.Error.INVALID_ARGUMENT, + message: 'Unsupported channel type' })); + }, + + _approveTextChannel: function(account, conn, channel, dispatchOp, context) { + let [targetHandle, targetHandleType] = channel.get_handle(); + + if (targetHandleType == Tp.HandleType.CONTACT) { + // Approve private text channels right away as we are going to handle it + dispatchOp.claim_with_async(this._tpClient, + Lang.bind(this, function(dispatchOp, result) { + try { + dispatchOp.claim_with_finish(result); + this._handlingChannels(account, conn, [channel], false); + } catch (err) { + throw new Error('Failed to Claim channel: ' + err); + }})); + + context.accept(); + } else { + this._displayRoomInvitation(conn, channel, dispatchOp, context); + } + }, + + _approveCall: function(account, conn, channel, dispatchOp, context) { + let isVideo = false; + + let props = channel.borrow_immutable_properties(); + + if (props[Tp.PROP_CHANNEL_TYPE_CALL_INITIAL_VIDEO]) + isVideo = true; + + // We got the TpContact + let source = new ApproverSource(dispatchOp, _("Call"), isVideo ? + Gio.icon_new_for_string('camera-web') : + Gio.icon_new_for_string('audio-input-microphone')); + Main.messageTray.add(source); + + let notif = new AudioVideoNotification(source, dispatchOp, channel, + channel.get_target_contact(), isVideo); + source.notify(notif); + context.accept(); + }, + + _approveFileTransfer: function(account, conn, channel, dispatchOp, context) { + // Use the icon of the file being transferred + let gicon = Gio.content_type_get_icon(channel.get_mime_type()); + + // We got the TpContact + let source = new ApproverSource(dispatchOp, _("File Transfer"), gicon); + Main.messageTray.add(source); + + let notif = new FileTransferNotification(source, dispatchOp, channel, + channel.get_target_contact()); + source.notify(notif); + context.accept(); + }, + + _delegatedChannelsCb: function(client, channels) { + // Nothing to do as we don't make a distinction between observed and + // handled channels. + }, + + _accountManagerPrepared: function(am, result) { + am.prepare_finish(result); + + let accounts = am.get_valid_accounts(); + for (let i = 0; i < accounts.length; i++) { + this._accountValidityChanged(am, accounts[i], true); + } + }, + + _accountValidityChanged: function(am, account, valid) { + if (!valid) + return; + + // It would be better to connect to "status-changed" but we cannot. + // See discussion in https://bugzilla.gnome.org/show_bug.cgi?id=654159 + account.connect("notify::connection-status", + Lang.bind(this, this._accountConnectionStatusNotifyCb)); + + account.connect('notify::connection', + Lang.bind(this, this._connectionChanged)); + this._connectionChanged(account); + }, + + _connectionChanged: function(account) { + let conn = account.get_connection(); + if (conn == null) + return; + + this._tpClient.grab_contact_list_changed(conn); + if (conn.get_contact_list_state() == Tp.ContactListState.SUCCESS) { + this._contactListChanged(conn, conn.dup_contact_list(), []); + } + }, + + _contactListChanged: function(conn, added, removed) { + for (let i = 0; i < added.length; i++) { + let contact = added[i]; + + contact.connect('subscription-states-changed', + Lang.bind(this, this._subscriptionStateChanged)); + this._subscriptionStateChanged(contact); + } + }, + + _subscriptionStateChanged: function(contact) { + if (contact.get_publish_state() != Tp.SubscriptionState.ASK) + return; + + /* Implicitly accept publish requests if contact is already subscribed */ + if (contact.get_subscribe_state() == Tp.SubscriptionState.YES || + contact.get_subscribe_state() == Tp.SubscriptionState.ASK) { + + contact.authorize_publication_async(function(src, result) { + src.authorize_publication_finish(result)}); + + return; + } + + /* Display notification to ask user to accept/reject request */ + let source = this._ensureSubscriptionSource(); + + let notif = new SubscriptionRequestNotification(source, contact); + source.notify(notif); + }, + + _ensureSubscriptionSource: function() { + if (this._subscriptionSource == null) { + this._subscriptionSource = new MessageTray.Source(_("Subscription request"), + 'gtk-dialog-question'); + Main.messageTray.add(this._subscriptionSource); + this._subscriptionSource.connect('destroy', Lang.bind(this, function () { + this._subscriptionSource = null; + })); + } + + return this._subscriptionSource; + }, + + _accountConnectionStatusNotifyCb: function(account) { + let connectionError = account.connection_error; + + if (account.connection_status != Tp.ConnectionStatus.DISCONNECTED || + connectionError == Tp.error_get_dbus_name(Tp.Error.CANCELLED)) { + return; + } + + let notif = this._accountNotifications[account.get_object_path()]; + if (notif) + return; + + /* Display notification that account failed to connect */ + let source = this._ensureAccountSource(); + + notif = new AccountNotification(source, account, connectionError); + this._accountNotifications[account.get_object_path()] = notif; + notif.connect('destroy', Lang.bind(this, function() { + delete this._accountNotifications[account.get_object_path()]; + })); + source.notify(notif); + }, + + _ensureAccountSource: function() { + if (this._accountSource == null) { + this._accountSource = new MessageTray.Source(_("Connection error"), + 'gtk-dialog-error'); + Main.messageTray.add(this._accountSource); + this._accountSource.connect('destroy', Lang.bind(this, function () { + this._accountSource = null; + })); + } + + return this._accountSource; + } +}); + +const ChatSource = new Lang.Class({ + Name: 'ChatSource', + Extends: MessageTray.Source, + + _init: function(account, conn, channel, contact, client) { + this._account = account; + this._contact = contact; + this._client = client; + + this.parent(contact.get_alias()); + + this.isChat = true; + this._pendingMessages = []; + + this._conn = conn; + this._channel = channel; + this._closedId = this._channel.connect('invalidated', Lang.bind(this, this._channelClosed)); + + this._notification = new ChatNotification(this); + this._notification.setUrgency(MessageTray.Urgency.HIGH); + this._notifyTimeoutId = 0; + + // We ack messages when the user expands the new notification or views the summary + // notification, in which case the notification is also expanded. + this._notification.connect('expanded', Lang.bind(this, this._ackMessages)); + + this._presence = contact.get_presence_type(); + + this._sentId = this._channel.connect('message-sent', Lang.bind(this, this._messageSent)); + this._receivedId = this._channel.connect('message-received', Lang.bind(this, this._messageReceived)); + this._pendingId = this._channel.connect('pending-message-removed', Lang.bind(this, this._pendingRemoved)); + + this._notifyAliasId = this._contact.connect('notify::alias', Lang.bind(this, this._updateAlias)); + this._notifyAvatarId = this._contact.connect('notify::avatar-file', Lang.bind(this, this._updateAvatarIcon)); + this._presenceChangedId = this._contact.connect('presence-changed', Lang.bind(this, this._presenceChanged)); + + // Add ourselves as a source. + Main.messageTray.add(this); + this.pushNotification(this._notification); + + this._getLogMessages(); + }, + + _updateAlias: function() { + let oldAlias = this.title; + let newAlias = this._contact.get_alias(); + + if (oldAlias == newAlias) + return; + + this.setTitle(newAlias); + this._notification.appendAliasChange(oldAlias, newAlias); + }, + + createIcon: function(size) { + this._iconBox = new St.Bin({ style_class: 'avatar-box' }); + this._iconBox._size = size; + let textureCache = St.TextureCache.get_default(); + let file = this._contact.get_avatar_file(); + + if (file) { + let uri = file.get_uri(); + this._iconBox.child = textureCache.load_uri_async(uri, this._iconBox._size, this._iconBox._size); + } else { + this._iconBox.child = new St.Icon({ icon_name: 'avatar-default', + icon_size: this._iconBox._size }); + } + + return this._iconBox; + }, + + createSecondaryIcon: function() { + let iconBox = new St.Bin(); + iconBox.child = new St.Icon({ style_class: 'secondary-icon' }); + let presenceType = this._contact.get_presence_type(); + + switch (presenceType) { + case Tp.ConnectionPresenceType.AVAILABLE: + iconBox.child.icon_name = 'user-available'; + break; + case Tp.ConnectionPresenceType.BUSY: + iconBox.child.icon_name = 'user-busy'; + break; + case Tp.ConnectionPresenceType.OFFLINE: + iconBox.child.icon_name = 'user-offline'; + break; + case Tp.ConnectionPresenceType.HIDDEN: + iconBox.child.icon_name = 'user-invisible'; + break; + case Tp.ConnectionPresenceType.AWAY: + iconBox.child.icon_name = 'user-away'; + break; + case Tp.ConnectionPresenceType.EXTENDED_AWAY: + iconBox.child.icon_name = 'user-idle'; + break; + default: + iconBox.child.icon_name = 'user-offline'; + } + return iconBox; + }, + + _updateAvatarIcon: function() { + this.iconUpdated(); + this._notification.update(this._notification.title, null, { customContent: true }); + }, + + open: function(notification) { + if (this._client.is_handling_channel(this._channel)) { + // We are handling the channel, try to pass it to Empathy + this._client.delegate_channels_async([this._channel], global.get_current_time(), '', null); + } + else { + // We are not the handler, just ask to present the channel + let dbus = Tp.DBusDaemon.dup(); + let cd = Tp.ChannelDispatcher.new(dbus); + + cd.present_channel_async(this._channel, global.get_current_time(), null); + } + }, + + _getLogMessages: function() { + let logManager = Tpl.LogManager.dup_singleton(); + let entity = Tpl.Entity.new_from_tp_contact(this._contact, Tpl.EntityType.CONTACT); + + logManager.get_filtered_events_async(this._account, entity, + Tpl.EventTypeMask.TEXT, SCROLLBACK_HISTORY_LINES, + null, Lang.bind(this, this._displayPendingMessages)); + }, + + _displayPendingMessages: function(logManager, result) { + let [success, events] = logManager.get_filtered_events_finish(result); + + let logMessages = events.map(makeMessageFromTplEvent); + + let pendingTpMessages = this._channel.get_pending_messages(); + let pendingMessages = []; + + for (let i = 0; i < pendingTpMessages.length; i++) { + let message = pendingTpMessages[i]; + + if (message.get_message_type() == Tp.ChannelTextMessageType.DELIVERY_REPORT) + continue; + + pendingMessages.push(makeMessageFromTpMessage(message, NotificationDirection.RECEIVED)); + + this._pendingMessages.push(message); + } + + this.countUpdated(); + + let showTimestamp = false; + + for (let i = 0; i < logMessages.length; i++) { + let logMessage = logMessages[i]; + let isPending = false; + + // Skip any log messages that are also in pendingMessages + for (let j = 0; j < pendingMessages.length; j++) { + let pending = pendingMessages[j]; + if (logMessage.timestamp == pending.timestamp && logMessage.text == pending.text) { + isPending = true; + break; + } + } + + if (!isPending) { + showTimestamp = true; + this._notification.appendMessage(logMessage, true, ['chat-log-message']); + } + } + + if (showTimestamp) + this._notification.appendTimestamp(); + + for (let i = 0; i < pendingMessages.length; i++) + this._notification.appendMessage(pendingMessages[i], true); + + if (pendingMessages.length > 0) + this.notify(); + }, + + _channelClosed: function() { + this._channel.disconnect(this._closedId); + this._channel.disconnect(this._receivedId); + this._channel.disconnect(this._pendingId); + this._channel.disconnect(this._sentId); + + this._contact.disconnect(this._notifyAliasId); + this._contact.disconnect(this._notifyAvatarId); + this._contact.disconnect(this._presenceChangedId); + + this.destroy(); + }, + + /* All messages are new messages for Telepathy sources */ + get count() { + return this._pendingMessages.length; + }, + + get unseenCount() { + return this.count; + }, + + get countVisible() { + return this.count > 0; + }, + + _messageReceived: function(channel, message) { + if (message.get_message_type() == Tp.ChannelTextMessageType.DELIVERY_REPORT) + return; + + this._pendingMessages.push(message); + this.countUpdated(); + + message = makeMessageFromTpMessage(message, NotificationDirection.RECEIVED); + this._notification.appendMessage(message); + + // Wait a bit before notifying for the received message, a handler + // could ack it in the meantime. + if (this._notifyTimeoutId != 0) + Mainloop.source_remove(this._notifyTimeoutId); + this._notifyTimeoutId = Mainloop.timeout_add(500, + Lang.bind(this, this._notifyTimeout)); + }, + + _notifyTimeout: function() { + if (this._pendingMessages.length != 0) + this.notify(); + + this._notifyTimeoutId = 0; + + return false; + }, + + // This is called for both messages we send from + // our client and other clients as well. + _messageSent: function(channel, message, flags, token) { + message = makeMessageFromTpMessage(message, NotificationDirection.SENT); + this._notification.appendMessage(message); + }, + + notify: function() { + this.parent(this._notification); + }, + + respond: function(text) { + let type; + if (text.slice(0, 4) == '/me ') { + type = Tp.ChannelTextMessageType.ACTION; + text = text.slice(4); + } else { + type = Tp.ChannelTextMessageType.NORMAL; + } + + let msg = Tp.ClientMessage.new_text(type, text); + this._channel.send_message_async(msg, 0, Lang.bind(this, function (src, result) { + this._channel.send_message_finish(result); + })); + }, + + setChatState: function(state) { + // We don't want to send COMPOSING every time a letter is typed into + // the entry. We send the state only when it changes. Telepathy/Empathy + // might change it behind our back if the user is using both + // gnome-shell's entry and the Empathy conversation window. We could + // keep track of it with the ChatStateChanged signal but it is good + // enough right now. + if (state != this._chatState) { + this._chatState = state; + this._channel.set_chat_state_async(state, null); + } + }, + + _presenceChanged: function (contact, presence, status, message) { + let msg, title; + + title = GLib.markup_escape_text(this.title, -1); + + this._notification.update(this._notification.title, null, { customContent: true, secondaryIcon: this.createSecondaryIcon() }); + + if (message) + msg += ' (' + GLib.markup_escape_text(message, -1) + ')'; + }, + + _pendingRemoved: function(channel, message) { + let idx = this._pendingMessages.indexOf(message); + + if (idx >= 0) { + this._pendingMessages.splice(idx, 1); + this.countUpdated(); + } + else + throw new Error('Message not in our pending list: ' + message); + }, + + _ackMessages: function() { + // Don't clear our messages here, tp-glib will send a + // 'pending-message-removed' for each one. + this._channel.ack_all_pending_messages_async(Lang.bind(this, function(src, result) { + this._channel.ack_all_pending_messages_finish(result);})); + } +}); + +const ChatNotification = new Lang.Class({ + Name: 'ChatNotification', + Extends: MessageTray.Notification, + + _init: function(source) { + this.parent(source, source.title, null, { customContent: true, secondaryIcon: source.createSecondaryIcon() }); + this.setResident(true); + + this._responseEntry = new St.Entry({ style_class: 'chat-response', + can_focus: true }); + this._responseEntry.clutter_text.connect('activate', Lang.bind(this, this._onEntryActivated)); + this._responseEntry.clutter_text.connect('text-changed', Lang.bind(this, this._onEntryChanged)); + this.setActionArea(this._responseEntry); + + this._oldMaxScrollAdjustment = 0; + this._createScrollArea(); + this._lastGroup = null; + this._lastGroupActor = null; + + this._scrollArea.vscroll.adjustment.connect('changed', Lang.bind(this, function(adjustment) { + let currentValue = adjustment.value + adjustment.page_size; + if (currentValue == this._oldMaxScrollAdjustment) + this.scrollTo(St.Side.BOTTOM); + this._oldMaxScrollAdjustment = adjustment.upper; + })); + + this._inputHistory = new History.HistoryManager({ entry: this._responseEntry.clutter_text }); + + this._history = []; + this._timestampTimeoutId = 0; + this._composingTimeoutId = 0; + }, + + /** + * appendMessage: + * @message: An object with the properties: + * text: the body of the message, + * messageType: a #Tp.ChannelTextMessageType, + * sender: the name of the sender, + * timestamp: the time the message was sent + * direction: a #NotificationDirection + * + * @noTimestamp: Whether to add a timestamp. If %true, no timestamp + * will be added, regardless of the difference since the + * last timestamp + */ + appendMessage: function(message, noTimestamp) { + let messageBody = GLib.markup_escape_text(message.text, -1); + let styles = [message.direction]; + + if (message.messageType == Tp.ChannelTextMessageType.ACTION) { + let senderAlias = GLib.markup_escape_text(message.sender, -1); + messageBody = '%s %s'.format(senderAlias, messageBody); + styles.push('chat-action'); + } + + if (message.direction == NotificationDirection.RECEIVED) { + this.update(this.source.title, messageBody, { customContent: true, + bannerMarkup: true }); + } + + let group = (message.direction == NotificationDirection.RECEIVED ? + 'received' : 'sent'); + + this._append({ body: messageBody, + group: group, + styles: styles, + timestamp: message.timestamp, + noTimestamp: noTimestamp }); + }, + + _filterMessages: function() { + if (this._history.length < 1) + return; + + let lastMessageTime = this._history[0].time; + let currentTime = (Date.now() / 1000); + + // Keep the scrollback from growing too long. If the most + // recent message (before the one we just added) is within + // SCROLLBACK_RECENT_TIME, we will keep + // SCROLLBACK_RECENT_LENGTH previous messages. Otherwise + // we'll keep SCROLLBACK_IDLE_LENGTH messages. + + let maxLength = (lastMessageTime < currentTime - SCROLLBACK_RECENT_TIME) ? + SCROLLBACK_IDLE_LENGTH : SCROLLBACK_RECENT_LENGTH; + + let filteredHistory = this._history.filter(function(item) { return item.realMessage }); + if (filteredHistory.length > maxLength) { + let lastMessageToKeep = filteredHistory[maxLength]; + let expired = this._history.splice(this._history.indexOf(lastMessageToKeep)); + for (let i = 0; i < expired.length; i++) + expired[i].actor.destroy(); + } + + let groups = this._contentArea.get_children(); + for (let i = 0; i < groups.length; i++) { + let group = groups[i]; + if (group.get_n_children() == 0) + group.destroy(); + } + }, + + /** + * _append: + * @props: An object with the properties: + * body: The text of the message. + * group: The group of the message, one of: + * 'received', 'sent', 'meta'. + * styles: Style class names for the message to have. + * timestamp: The timestamp of the message. + * noTimestamp: suppress timestamp signal? + * childProps: props to add the actor with. + */ + _append: function(props) { + let currentTime = (Date.now() / 1000); + props = Params.parse(props, { body: null, + group: null, + styles: [], + timestamp: currentTime, + noTimestamp: false, + childProps: null }); + + // Reset the old message timeout + if (this._timestampTimeoutId) + Mainloop.source_remove(this._timestampTimeoutId); + + let highlighter = new MessageTray.URLHighlighter(props.body, + true, // line wrap? + true); // allow markup? + + let body = highlighter.actor; + + let styles = props.styles; + for (let i = 0; i < styles.length; i++) + body.add_style_class_name(styles[i]); + + let group = props.group; + if (group != this._lastGroup) { + let style = 'chat-group-' + group; + this._lastGroup = group; + this._lastGroupActor = new St.BoxLayout({ style_class: style, + vertical: true }); + this.addActor(this._lastGroupActor); + } + + this._lastGroupActor.add(body, props.childProps); + + this.updated(); + + let timestamp = props.timestamp; + this._history.unshift({ actor: body, time: timestamp, + realMessage: group != 'meta' }); + + if (!props.noTimestamp) { + if (timestamp < currentTime - SCROLLBACK_IMMEDIATE_TIME) + this.appendTimestamp(); + else + // Schedule a new timestamp in SCROLLBACK_IMMEDIATE_TIME + // from the timestamp of the message. + this._timestampTimeoutId = Mainloop.timeout_add_seconds( + SCROLLBACK_IMMEDIATE_TIME - (currentTime - timestamp), + Lang.bind(this, this.appendTimestamp)); + } + + this._filterMessages(); + }, + + _formatTimestamp: function(date) { + let now = new Date(); + + var daysAgo = (now.getTime() - date.getTime()) / (24 * 60 * 60 * 1000); + + let format; + + // Show a week day and time if date is in the last week + if (daysAgo < 1 || (daysAgo < 7 && now.getDay() != date.getDay())) { + /* Translators: this is a time format string followed by a date. + If applicable, replace %X with a strftime format valid for your + locale, without seconds. */ + // xgettext:no-c-format + format = _("Sent at %X on %A"); + + } else if (date.getYear() == now.getYear()) { + /* Translators: this is a time format in the style of "Wednesday, May 25", + shown when you get a chat message in the same year. */ + // xgettext:no-c-format + format = _("Sent on %A, %B %d"); + } else { + /* Translators: this is a time format in the style of "Wednesday, May 25, 2012", + shown when you get a chat message in a different year. */ + // xgettext:no-c-format + format = _("Sent on %A, %B %d, %Y"); + } + + return date.toLocaleFormat(format); + }, + + appendTimestamp: function() { + let lastMessageTime = this._history[0].time; + let lastMessageDate = new Date(lastMessageTime * 1000); + + let timeLabel = this._append({ body: this._formatTimestamp(lastMessageDate), + group: 'meta', + styles: ['chat-meta-message'], + childProps: { expand: true, x_fill: false, + x_align: St.Align.END }, + noTimestamp: true, + timestamp: lastMessageTime }); + + this._filterMessages(); + + return false; + }, + + appendAliasChange: function(oldAlias, newAlias) { + oldAlias = GLib.markup_escape_text(oldAlias, -1); + newAlias = GLib.markup_escape_text(newAlias, -1); + + /* Translators: this is the other person changing their old IM name to their new + IM name. */ + let message = '' + _("%s is now known as %s").format(oldAlias, newAlias) + ''; + + let label = this._append({ body: message, + group: 'meta', + styles: ['chat-meta-message'] }); + + this.update(newAlias, null, { customContent: true }); + + this._filterMessages(); + }, + + _onEntryActivated: function() { + let text = this._responseEntry.get_text(); + if (text == '') + return; + + this._inputHistory.addItem(text); + + // Telepathy sends out the Sent signal for us. + // see Source._messageSent + this._responseEntry.set_text(''); + this.source.respond(text); + }, + + _composingStopTimeout: function() { + this._composingTimeoutId = 0; + + this.source.setChatState(Tp.ChannelChatState.PAUSED); + + return false; + }, + + _onEntryChanged: function() { + let text = this._responseEntry.get_text(); + + // If we're typing, we want to send COMPOSING. + // If we empty the entry, we want to send ACTIVE. + // If we've stopped typing for COMPOSING_STOP_TIMEOUT + // seconds, we want to send PAUSED. + + // Remove composing timeout. + if (this._composingTimeoutId > 0) { + Mainloop.source_remove(this._composingTimeoutId); + this._composingTimeoutId = 0; + } + + if (text != '') { + this.source.setChatState(Tp.ChannelChatState.COMPOSING); + + this._composingTimeoutId = Mainloop.timeout_add_seconds( + COMPOSING_STOP_TIMEOUT, + Lang.bind(this, this._composingStopTimeout)); + } else { + this.source.setChatState(Tp.ChannelChatState.ACTIVE); + } + } +}); + +const ApproverSource = new Lang.Class({ + Name: 'ApproverSource', + Extends: MessageTray.Source, + + _init: function(dispatchOp, text, gicon) { + this._gicon = gicon; + + this.parent(text); + + this._dispatchOp = dispatchOp; + + // Destroy the source if the channel dispatch operation is invalidated + // as we can't approve any more. + this._invalidId = dispatchOp.connect('invalidated', + Lang.bind(this, function(domain, code, msg) { + this.destroy(); + })); + }, + + destroy: function() { + if (this._invalidId != 0) { + this._dispatchOp.disconnect(this._invalidId); + this._invalidId = 0; + } + + this.parent(); + }, + + createIcon: function(size) { + return new St.Icon({ gicon: this._gicon, + icon_size: size }); + } +}); + +const RoomInviteNotification = new Lang.Class({ + Name: 'RoomInviteNotification', + Extends: MessageTray.Notification, + + _init: function(source, dispatchOp, channel, inviter) { + this.parent(source, + /* translators: argument is a room name like + * room@jabber.org for example. */ + _("Invitation to %s").format(channel.get_identifier()), + null, + { customContent: true }); + this.setResident(true); + + /* translators: first argument is the name of a contact and the second + * one the name of a room. "Alice is inviting you to join room@jabber.org + * for example. */ + this.addBody(_("%s is inviting you to join %s").format(inviter.get_alias(), channel.get_identifier())); + + this.addButton('decline', _("Decline")); + this.addButton('accept', _("Accept")); + + this.connect('action-invoked', Lang.bind(this, function(self, action) { + switch (action) { + case 'decline': + dispatchOp.leave_channels_async(Tp.ChannelGroupChangeReason.NONE, + '', function(src, result) { + src.leave_channels_finish(result)}); + break; + case 'accept': + dispatchOp.handle_with_time_async('', global.get_current_time(), + function(src, result) { + src.handle_with_time_finish(result)}); + break; + } + this.destroy(); + })); + } +}); + +// Audio Video +const AudioVideoNotification = new Lang.Class({ + Name: 'AudioVideoNotification', + Extends: MessageTray.Notification, + + _init: function(source, dispatchOp, channel, contact, isVideo) { + let title = ''; + + if (isVideo) + /* translators: argument is a contact name like Alice for example. */ + title = _("Video call from %s").format(contact.get_alias()); + else + /* translators: argument is a contact name like Alice for example. */ + title = _("Call from %s").format(contact.get_alias()); + + this.parent(source, title, null, { customContent: true }); + this.setResident(true); + + this.addButton('reject', _("Reject")); + /* translators: this is a button label (verb), not a noun */ + this.addButton('answer', _("Answer")); + + this.connect('action-invoked', Lang.bind(this, function(self, action) { + switch (action) { + case 'reject': + dispatchOp.leave_channels_async(Tp.ChannelGroupChangeReason.NONE, + '', function(src, result) { + src.leave_channels_finish(result)}); + break; + case 'answer': + dispatchOp.handle_with_time_async('', global.get_current_time(), + function(src, result) { + src.handle_with_time_finish(result)}); + break; + } + this.destroy(); + })); + } +}); + +// File Transfer +const FileTransferNotification = new Lang.Class({ + Name: 'FileTransferNotification', + Extends: MessageTray.Notification, + + _init: function(source, dispatchOp, channel, contact) { + this.parent(source, + /* To translators: The first parameter is + * the contact's alias and the second one is the + * file name. The string will be something + * like: "Alice is sending you test.ogg" + */ + _("%s is sending you %s").format(contact.get_alias(), + channel.get_filename()), + null, + { customContent: true }); + this.setResident(true); + + this.addButton('decline', _("Decline")); + this.addButton('accept', _("Accept")); + + this.connect('action-invoked', Lang.bind(this, function(self, action) { + switch (action) { + case 'decline': + dispatchOp.leave_channels_async(Tp.ChannelGroupChangeReason.NONE, + '', function(src, result) { + src.leave_channels_finish(result)}); + break; + case 'accept': + dispatchOp.handle_with_time_async('', global.get_current_time(), + function(src, result) { + src.handle_with_time_finish(result)}); + break; + } + this.destroy(); + })); + } +}); + +// Subscription request +const SubscriptionRequestNotification = new Lang.Class({ + Name: 'SubscriptionRequestNotification', + Extends: MessageTray.Notification, + + _init: function(source, contact) { + this.parent(source, + /* To translators: The parameter is the contact's alias */ + _("%s would like permission to see when you are online").format(contact.get_alias()), + null, { customContent: true }); + + this._contact = contact; + this._connection = contact.get_connection(); + + let layout = new St.BoxLayout({ vertical: false }); + + // Display avatar + let iconBox = new St.Bin({ style_class: 'avatar-box' }); + iconBox._size = 48; + + let textureCache = St.TextureCache.get_default(); + let file = contact.get_avatar_file(); + + if (file) { + let uri = file.get_uri(); + iconBox.child = textureCache.load_uri_async(uri, iconBox._size, iconBox._size); + } + else { + iconBox.child = new St.Icon({ icon_name: 'avatar-default', + icon_size: iconBox._size }); + } + + layout.add(iconBox); + + // subscription request message + let label = new St.Label({ style_class: 'subscription-message', + text: contact.get_publish_request() }); + + layout.add(label); + + this.addActor(layout); + + this.addButton('decline', _("Decline")); + this.addButton('accept', _("Accept")); + + this.connect('action-invoked', Lang.bind(this, function(self, action) { + switch (action) { + case 'decline': + contact.remove_async(function(src, result) { + src.remove_finish(result)}); + break; + case 'accept': + // Authorize the contact and request to see his status as well + contact.authorize_publication_async(function(src, result) { + src.authorize_publication_finish(result)}); + + contact.request_subscription_async('', function(src, result) { + src.request_subscription_finish(result)}); + break; + } + + // rely on _subscriptionStatesChangedCb to destroy the + // notification + })); + + this._changedId = contact.connect('subscription-states-changed', + Lang.bind(this, this._subscriptionStatesChangedCb)); + this._invalidatedId = this._connection.connect('invalidated', + Lang.bind(this, this.destroy)); + }, + + destroy: function() { + if (this._changedId != 0) { + this._contact.disconnect(this._changedId); + this._changedId = 0; + } + + if (this._invalidatedId != 0) { + this._connection.disconnect(this._invalidatedId); + this._invalidatedId = 0; + } + + this.parent(); + }, + + _subscriptionStatesChangedCb: function(contact, subscribe, publish, msg) { + // Destroy the notification if the subscription request has been + // answered + if (publish != Tp.SubscriptionState.ASK) + this.destroy(); + } +}); + +// Messages from empathy/libempathy/empathy-utils.c +// create_errors_to_message_hash() + +/* Translator note: these should be the same messages that are + * used in Empathy, so just copy and paste from there. */ +let _connectionErrorMessages = {}; +_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.NETWORK_ERROR)] + = _("Network error"); +_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.AUTHENTICATION_FAILED)] + = _("Authentication failed"); +_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.ENCRYPTION_ERROR)] + = _("Encryption error"); +_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CERT_NOT_PROVIDED)] + = _("Certificate not provided"); +_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CERT_UNTRUSTED)] + = _("Certificate untrusted"); +_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CERT_EXPIRED)] + = _("Certificate expired"); +_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CERT_NOT_ACTIVATED)] + = _("Certificate not activated"); +_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CERT_HOSTNAME_MISMATCH)] + = _("Certificate hostname mismatch"); +_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CERT_FINGERPRINT_MISMATCH)] + = _("Certificate fingerprint mismatch"); +_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CERT_SELF_SIGNED)] + = _("Certificate self-signed"); +_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CANCELLED)] + = _("Status is set to offline"); +_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.ENCRYPTION_NOT_AVAILABLE)] + = _("Encryption is not available"); +_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CERT_INVALID)] + = _("Certificate is invalid"); +_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CONNECTION_REFUSED)] + = _("Connection has been refused"); +_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CONNECTION_FAILED)] + = _("Connection can't be established"); +_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CONNECTION_LOST)] + = _("Connection has been lost"); +_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.ALREADY_CONNECTED)] + = _("This account is already connected to the server"); +_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CONNECTION_REPLACED)] + = _("Connection has been replaced by a new connection using the same resource"); +_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.REGISTRATION_EXISTS)] + = _("The account already exists on the server"); +_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.SERVICE_BUSY)] + = _("Server is currently too busy to handle the connection"); +_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CERT_REVOKED)] + = _("Certificate has been revoked"); +_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CERT_INSECURE)] + = _("Certificate uses an insecure cipher algorithm or is cryptographically weak"); +_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CERT_LIMIT_EXCEEDED)] + = _("The length of the server certificate, or the depth of the server certificate chain, exceed the limits imposed by the cryptography library"); +_connectionErrorMessages['org.freedesktop.DBus.Error.NoReply'] + = _("Internal error"); + +const AccountNotification = new Lang.Class({ + Name: 'AccountNotification', + Extends: MessageTray.Notification, + + _init: function(source, account, connectionError) { + this.parent(source, + /* translators: argument is the account name, like + * name@jabber.org for example. */ + _("Connection to %s failed").format(account.get_display_name()), + null, { customContent: true }); + + this._label = new St.Label(); + this.addActor(this._label); + this._updateMessage(connectionError); + + this._account = account; + + this.addButton('reconnect', _("Reconnect")); + this.addButton('edit', _("Edit account")); + + this.connect('action-invoked', Lang.bind(this, function(self, action) { + switch (action) { + case 'reconnect': + // If it fails again, a new notification should pop up with the + // new error. + account.reconnect_async(null); + break; + case 'edit': + let cmd = '/usr/bin/empathy-accounts' + + ' --select-account=%s' + .format(account.get_path_suffix()); + let app_info = Gio.app_info_create_from_commandline(cmd, null, 0); + app_info.launch([], global.create_app_launch_context()); + break; + } + this.destroy(); + })); + + this._enabledId = account.connect('notify::enabled', + Lang.bind(this, function() { + if (!account.is_enabled()) + this.destroy(); + })); + + this._invalidatedId = account.connect('invalidated', + Lang.bind(this, this.destroy)); + + this._connectionStatusId = account.connect('notify::connection-status', + Lang.bind(this, function() { + let status = account.connection_status; + if (status == Tp.ConnectionStatus.CONNECTED) { + this.destroy(); + } else if (status == Tp.ConnectionStatus.DISCONNECTED) { + this._updateMessage(account.connection_error); + } + })); + }, + + _updateMessage: function(connectionError) { + let message; + if (connectionError in _connectionErrorMessages) { + message = _connectionErrorMessages[connectionError]; + } else { + message = _("Unknown reason"); + } + this._label.set_text(message); + }, + + destroy: function() { + if (this._enabledId != 0) { + this._account.disconnect(this._enabledId); + this._enabledId = 0; + } + + if (this._invalidatedId != 0) { + this._account.disconnect(this._invalidatedId); + this._invalidatedId = 0; + } + + if (this._connectionStatusId != 0) { + this._account.disconnect(this._connectionStatusId); + this._connectionStatusId = 0; + } + + this.parent(); + } +}); +const Component = TelepathyClient; diff --git a/js/ui/keyringPrompt.js b/js/ui/keyringPrompt.js deleted file mode 100644 index 814c9467b..000000000 --- a/js/ui/keyringPrompt.js +++ /dev/null @@ -1,214 +0,0 @@ -// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- - -const Lang = imports.lang; -const Shell = imports.gi.Shell; -const Clutter = imports.gi.Clutter; -const St = imports.gi.St; -const Pango = imports.gi.Pango; -const Gio = imports.gi.Gio; -const GObject = imports.gi.GObject; -const Gcr = imports.gi.Gcr; - -const ModalDialog = imports.ui.modalDialog; -const ShellEntry = imports.ui.shellEntry; -const CheckBox = imports.ui.checkBox; - -let prompter = null; - -const KeyringDialog = new Lang.Class({ - Name: 'KeyringDialog', - Extends: ModalDialog.ModalDialog, - - _init: function() { - this.parent({ styleClass: 'prompt-dialog' }); - - this.prompt = new Shell.KeyringPrompt(); - this.prompt.connect('show-password', Lang.bind(this, this._onShowPassword)); - this.prompt.connect('show-confirm', Lang.bind(this, this._onShowConfirm)); - this.prompt.connect('hide-prompt', Lang.bind(this, this._onHidePrompt)); - - let mainContentBox = new St.BoxLayout({ style_class: 'prompt-dialog-main-layout', - vertical: false }); - this.contentLayout.add(mainContentBox); - - let icon = new St.Icon({ icon_name: 'dialog-password-symbolic' }); - mainContentBox.add(icon, - { x_fill: true, - y_fill: false, - x_align: St.Align.END, - y_align: St.Align.START }); - - this._messageBox = new St.BoxLayout({ style_class: 'prompt-dialog-message-layout', - vertical: true }); - mainContentBox.add(this._messageBox, - { y_align: St.Align.START, expand: true, x_fill: true, y_fill: true }); - - let subject = new St.Label({ style_class: 'prompt-dialog-headline' }); - this.prompt.bind_property('message', subject, 'text', GObject.BindingFlags.SYNC_CREATE); - - this._messageBox.add(subject, - { y_fill: false, - y_align: St.Align.START }); - - let description = new St.Label({ style_class: 'prompt-dialog-description' }); - description.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; - description.clutter_text.line_wrap = true; - this.prompt.bind_property('description', description, 'text', GObject.BindingFlags.SYNC_CREATE); - this._messageBox.add(description, - { y_fill: true, - y_align: St.Align.START }); - - this._controlTable = null; - - let buttons = [{ label: '', - action: Lang.bind(this, this._onCancelButton), - key: Clutter.Escape - }, - { label: '', - action: Lang.bind(this, this._onContinueButton), - default: true - }] - - this.setButtons(buttons); - this._cancelButton = buttons[0].button; - this._continueButton = buttons[1].button; - - this.prompt.bind_property('cancel-label', this._cancelButton, 'label', GObject.BindingFlags.SYNC_CREATE); - this.prompt.bind_property('continue-label', this._continueButton, 'label', GObject.BindingFlags.SYNC_CREATE); - }, - - _buildControlTable: function() { - let table = new St.Table({ style_class: 'keyring-dialog-control-table' }); - let row = 0; - - if (this.prompt.password_visible) { - let label = new St.Label(({ style_class: 'prompt-dialog-password-label' })); - label.set_text(_("Password:")); - table.add(label, { row: row, col: 0, - x_expand: false, x_fill: true, - x_align: St.Align.START, - y_fill: false, y_align: St.Align.MIDDLE }); - this._passwordEntry = new St.Entry({ style_class: 'prompt-dialog-password-entry', - text: '', - can_focus: true}); - this._passwordEntry.clutter_text.set_password_char('\u25cf'); // ● U+25CF BLACK CIRCLE - ShellEntry.addContextMenu(this._passwordEntry, { isPassword: true }); - this._passwordEntry.clutter_text.connect('activate', Lang.bind(this, this._onPasswordActivate)); - table.add(this._passwordEntry, { row: row, col: 1, x_expand: true, x_fill: true, x_align: St.Align.START }); - row++; - } else { - this._passwordEntry = null; - } - - if (this.prompt.confirm_visible) { - var label = new St.Label(({ style_class: 'prompt-dialog-password-label' })); - label.set_text(_("Type again:")); - table.add(label, { row: row, col: 0, - x_expand: false, x_fill: true, - x_align: St.Align.START, - y_fill: false, y_align: St.Align.MIDDLE }); - this._confirmEntry = new St.Entry({ style_class: 'prompt-dialog-password-entry', - text: '', - can_focus: true}); - this._confirmEntry.clutter_text.set_password_char('\u25cf'); // ● U+25CF BLACK CIRCLE - ShellEntry.addContextMenu(this._confirmEntry, { isPassword: true }); - this._confirmEntry.clutter_text.connect('activate', Lang.bind(this, this._onConfirmActivate)); - table.add(this._confirmEntry, { row: row, col: 1, x_expand: true, x_fill: true, x_align: St.Align.START }); - row++; - } else { - this._confirmEntry = null; - } - - this.prompt.set_password_actor(this._passwordEntry ? this._passwordEntry.clutter_text : null); - this.prompt.set_confirm_actor(this._confirmEntry ? this._confirmEntry.clutter_text : null); - - if (this.prompt.choice_visible) { - let choice = new CheckBox.CheckBox(); - this.prompt.bind_property('choice-label', choice.getLabelActor(), 'text', GObject.BindingFlags.SYNC_CREATE); - this.prompt.bind_property('choice-chosen', choice.actor, 'checked', GObject.BindingFlags.SYNC_CREATE | GObject.BindingFlags.BIDIRECTIONAL); - table.add(choice.actor, { row: row, col: 1, x_expand: false, x_fill: true, x_align: St.Align.START }); - row++; - } - - let warning = new St.Label({ style_class: 'prompt-dialog-error-label' }); - warning.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; - warning.clutter_text.line_wrap = true; - table.add(warning, { row: row, col: 1, x_expand: false, x_fill: false, x_align: St.Align.START }); - this.prompt.bind_property('warning-visible', warning, 'visible', GObject.BindingFlags.SYNC_CREATE); - this.prompt.bind_property('warning', warning, 'text', GObject.BindingFlags.SYNC_CREATE); - - if (this._controlTable) { - this._controlTable.destroy_all_children(); - this._controlTable.destroy(); - } - - this._controlTable = table; - this._messageBox.add(table, { x_fill: true, y_fill: true }); - }, - - _ensureOpen: function() { - // NOTE: ModalDialog.open() is safe to call if the dialog is - // already open - it just returns true without side-effects - if (this.open()) - return true; - - // The above fail if e.g. unable to get input grab - // - // In an ideal world this wouldn't happen (because the - // Shell is in complete control of the session) but that's - // just not how things work right now. - - log('keyringPrompt: Failed to show modal dialog.' + - ' Dismissing prompt request'); - this.prompt.cancel() - return false; - }, - - _onShowPassword: function(prompt) { - this._buildControlTable(); - this._ensureOpen(); - this._passwordEntry.grab_key_focus(); - }, - - _onShowConfirm: function(prompt) { - this._buildControlTable(); - this._ensureOpen(); - this._continueButton.grab_key_focus(); - }, - - _onHidePrompt: function(prompt) { - this.close(); - }, - - _onPasswordActivate: function() { - if (this.prompt.confirm_visible) - this._confirmEntry.grab_key_focus(); - else - this._onContinueButton(); - }, - - _onConfirmActivate: function() { - this._onContinueButton(); - }, - - _onContinueButton: function() { - this.prompt.complete() - }, - - _onCancelButton: function() { - this.prompt.cancel() - }, -}); - -function init() { - prompter = new Gcr.SystemPrompter(); - prompter.connect('new-prompt', function(prompter) { - let dialog = new KeyringDialog(); - return dialog.prompt; - }); - - let connection = Gio.DBus.session; - prompter.register(connection); - Gio.bus_own_name_on_connection (connection, 'org.gnome.keyring.SystemPrompter', - Gio.BusNameOwnerFlags.REPLACE, null, null); -} diff --git a/js/ui/main.js b/js/ui/main.js index 1d8ad4c95..9410c2352 100644 --- a/js/ui/main.js +++ b/js/ui/main.js @@ -10,12 +10,9 @@ const Meta = imports.gi.Meta; const Shell = imports.gi.Shell; const St = imports.gi.St; -const AutomountManager = imports.ui.automountManager; -const AutorunManager = imports.ui.autorunManager; +const Components = imports.ui.components; const CtrlAltTab = imports.ui.ctrlAltTab; const EndSessionDialog = imports.ui.endSessionDialog; -const PolkitAuthenticationAgent = imports.ui.polkitAuthenticationAgent; -const KeyringPrompt = imports.ui.keyringPrompt; const Environment = imports.ui.environment; const ExtensionSystem = imports.ui.extensionSystem; const ExtensionDownloader = imports.ui.extensionDownloader; @@ -27,7 +24,6 @@ const PlaceDisplay = imports.ui.placeDisplay; const RunDialog = imports.ui.runDialog; const Layout = imports.ui.layout; const LookingGlass = imports.ui.lookingGlass; -const NetworkAgent = imports.ui.networkAgent; const NotificationDaemon = imports.ui.notificationDaemon; const WindowAttentionHandler = imports.ui.windowAttentionHandler; const ScreenShield = imports.ui.screenShield; @@ -35,7 +31,6 @@ const Scripting = imports.ui.scripting; const SessionMode = imports.ui.sessionMode; const ShellDBus = imports.ui.shellDBus; const ShellMountOperation = imports.ui.shellMountOperation; -const TelepathyClient = imports.ui.telepathyClient; const UnlockDialog = imports.ui.unlockDialog; const WindowManager = imports.ui.windowManager; const Magnifier = imports.ui.magnifier; @@ -45,8 +40,7 @@ const Util = imports.misc.util; const OVERRIDES_SCHEMA = 'org.gnome.shell.overrides'; const DEFAULT_BACKGROUND_COLOR = Clutter.Color.from_pixel(0x2266bbff); -let automountManager = null; -let autorunManager = null; +let componentManager = null; let panel = null; let overview = null; let runDialog = null; @@ -56,9 +50,7 @@ let messageTray = null; let screenShield = null; let notificationDaemon = null; let windowAttentionHandler = null; -let telepathyClient = null; let ctrlAltTabManager = null; -let recorder = null; let sessionMode = null; let shellDBusService = null; let shellMountOpDBusService = null; @@ -70,7 +62,6 @@ let magnifier = null; let xdndHandler = null; let keyboard = null; let layoutManager = null; -let networkAgent = null; let _startDate; let _defaultCssStylesheet = null; let _cssStylesheet = null; @@ -78,19 +69,6 @@ let _overridesSettings = null; let background = null; -function createUserSession() { - telepathyClient = new TelepathyClient.Client(); - automountManager = new AutomountManager.AutomountManager(); - autorunManager = new AutorunManager.AutorunManager(); - networkAgent = new NetworkAgent.NetworkAgent(); - - _initRecorder(); -} - -function createGDMSession() { - screenShield.showDialog(); -} - function createGDMLoginDialog(parentActor) { // We do this this here instead of at the top to prevent GDM // related code from getting loaded in normal user sessions @@ -105,47 +83,8 @@ function createSessionUnlockDialog(parentActor) { return [dialog, false]; } -function createInitialSetupSession() { - networkAgent = new NetworkAgent.NetworkAgent(); -} - -function _initRecorder() { - let recorderSettings = new Gio.Settings({ schema: 'org.gnome.shell.recorder' }); - let desktopLockdownSettings = new Gio.Settings({ schema: 'org.gnome.desktop.lockdown' }); - let bindingSettings = new Gio.Settings({ schema: 'org.gnome.shell.keybindings' }); - - global.display.add_keybinding('toggle-recording', - bindingSettings, - Meta.KeyBindingFlags.NONE, function() { - if (recorder == null) { - recorder = new Shell.Recorder({ stage: global.stage }); - } - - if (recorder.is_recording()) { - recorder.close(); - Meta.enable_unredirect_for_screen(global.screen); - } else if (!desktopLockdownSettings.get_boolean('disable-save-to-disk')) { - // read the parameters from GSettings always in case they have changed - recorder.set_framerate(recorderSettings.get_int('framerate')); - /* Translators: this is a filename used for screencast recording */ - // xgettext:no-c-format - recorder.set_filename(_("Screencast from %d %t") + '.' + recorderSettings.get_string('file-extension')); - let pipeline = recorderSettings.get_string('pipeline'); - - if (!pipeline.match(/^\s*$/)) - recorder.set_pipeline(pipeline); - else - recorder.set_pipeline(null); - - Meta.disable_unredirect_for_screen(global.screen); - recorder.record(); - } - }); -} - function _sessionUpdated() { Meta.keybindings_set_custom_handler('panel-run-dialog', sessionMode.hasRunDialog ? openRunDialog : null); - loadTheme(); } function start() { @@ -219,8 +158,7 @@ function start() { keyboard = new Keyboard.Keyboard(); notificationDaemon = new NotificationDaemon.NotificationDaemon(); windowAttentionHandler = new WindowAttentionHandler.WindowAttentionHandler(); - - sessionMode.createSession(); + componentManager = new Components.ComponentManager(); layoutManager.init(); keyboard.init(); @@ -238,12 +176,6 @@ function start() { // initiate logouts. EndSessionDialog.init(); - // Attempt to become a PolicyKit authentication agent - PolkitAuthenticationAgent.init() - - // Become a prompter for gnome keyring - KeyringPrompt.init(); - _startDate = new Date(); global.stage.connect('captured-event', _globalKeyPressHandler); diff --git a/js/ui/networkAgent.js b/js/ui/networkAgent.js deleted file mode 100644 index dbfb75832..000000000 --- a/js/ui/networkAgent.js +++ /dev/null @@ -1,704 +0,0 @@ -// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- -/* - * Copyright 2011 Giovanni Campagna - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 2, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA - * 02111-1307, USA. - * - */ - -const Clutter = imports.gi.Clutter; -const Gio = imports.gi.Gio; -const GLib = imports.gi.GLib; -const GObject = imports.gi.GObject; -const Lang = imports.lang; -const NetworkManager = imports.gi.NetworkManager; -const NMClient = imports.gi.NMClient; -const Pango = imports.gi.Pango; -const Shell = imports.gi.Shell; -const St = imports.gi.St; - -const Config = imports.misc.config; -const ModalDialog = imports.ui.modalDialog; -const PopupMenu = imports.ui.popupMenu; -const ShellEntry = imports.ui.shellEntry; - -const VPN_UI_GROUP = 'VPN Plugin UI'; - -const NetworkSecretDialog = new Lang.Class({ - Name: 'NetworkSecretDialog', - Extends: ModalDialog.ModalDialog, - - _init: function(agent, requestId, connection, settingName, hints, contentOverride) { - this.parent({ styleClass: 'prompt-dialog' }); - - this._agent = agent; - this._requestId = requestId; - this._connection = connection; - this._settingName = settingName; - this._hints = hints; - - if (contentOverride) - this._content = contentOverride; - else - this._content = this._getContent(); - - let mainContentBox = new St.BoxLayout({ style_class: 'prompt-dialog-main-layout', - vertical: false }); - this.contentLayout.add(mainContentBox, - { x_fill: true, - y_fill: true }); - - let icon = new St.Icon({ icon_name: 'dialog-password-symbolic' }); - mainContentBox.add(icon, - { x_fill: true, - y_fill: false, - x_align: St.Align.END, - y_align: St.Align.START }); - - let messageBox = new St.BoxLayout({ style_class: 'prompt-dialog-message-layout', - vertical: true }); - mainContentBox.add(messageBox, - { y_align: St.Align.START }); - - let subjectLabel = new St.Label({ style_class: 'prompt-dialog-headline', - text: this._content.title }); - messageBox.add(subjectLabel, - { y_fill: false, - y_align: St.Align.START }); - - if (this._content.message != null) { - let descriptionLabel = new St.Label({ style_class: 'prompt-dialog-description', - text: this._content.message, - // HACK: for reasons unknown to me, the label - // is not asked the correct height for width, - // and thus is underallocated - // place a fixed height to avoid overflowing - style: 'height: 3em' - }); - descriptionLabel.clutter_text.line_wrap = true; - - messageBox.add(descriptionLabel, - { y_fill: true, - y_align: St.Align.START, - expand: true }); - } - - let secretTable = new St.Table({ style_class: 'network-dialog-secret-table' }); - let initialFocusSet = false; - let pos = 0; - for (let i = 0; i < this._content.secrets.length; i++) { - let secret = this._content.secrets[i]; - let label = new St.Label({ style_class: 'prompt-dialog-password-label', - text: secret.label }); - - let reactive = secret.key != null; - - secret.entry = new St.Entry({ style_class: 'prompt-dialog-password-entry', - text: secret.value, can_focus: reactive, - reactive: reactive }); - ShellEntry.addContextMenu(secret.entry, - { isPassword: secret.password }); - - if (secret.validate) - secret.valid = secret.validate(secret); - else // no special validation, just ensure it's not empty - secret.valid = secret.value.length > 0; - - if (reactive) { - if (!initialFocusSet) { - this.setInitialKeyFocus(secret.entry); - initialFocusSet = true; - } - - secret.entry.clutter_text.connect('activate', Lang.bind(this, this._onOk)); - secret.entry.clutter_text.connect('text-changed', Lang.bind(this, function() { - secret.value = secret.entry.get_text(); - if (secret.validate) - secret.valid = secret.validate(secret); - else - secret.valid = secret.value.length > 0; - this._updateOkButton(); - })); - } else - secret.valid = true; - - secretTable.add(label, { row: pos, col: 0, - x_expand: false, x_fill: true, - x_align: St.Align.START, - y_fill: false, y_align: St.Align.MIDDLE }); - secretTable.add(secret.entry, { row: pos, col: 1, x_expand: true, x_fill: true, y_align: St.Align.END }); - pos++; - - if (secret.password) - secret.entry.clutter_text.set_password_char('\u25cf'); - } - - messageBox.add(secretTable); - - this._okButton = { label: _("Connect"), - action: Lang.bind(this, this._onOk), - default: true - }; - - this.setButtons([{ label: _("Cancel"), - action: Lang.bind(this, this.cancel), - key: Clutter.KEY_Escape, - }, - this._okButton]); - }, - - _updateOkButton: function() { - let valid = true; - for (let i = 0; i < this._content.secrets.length; i++) { - let secret = this._content.secrets[i]; - valid = valid && secret.valid; - } - - this._okButton.button.reactive = valid; - this._okButton.button.can_focus = valid; - }, - - _onOk: function() { - let valid = true; - for (let i = 0; i < this._content.secrets.length; i++) { - let secret = this._content.secrets[i]; - valid = valid && secret.valid; - if (secret.key != null) - this._agent.set_password(this._requestId, secret.key, secret.value); - } - - if (valid) { - this._agent.respond(this._requestId, Shell.NetworkAgentResponse.CONFIRMED); - this.close(global.get_current_time()); - } - // do nothing if not valid - }, - - cancel: function() { - this._agent.respond(this._requestId, Shell.NetworkAgentResponse.USER_CANCELED); - this.close(global.get_current_time()); - }, - - _validateWpaPsk: function(secret) { - let value = secret.value; - if (value.length == 64) { - // must be composed of hexadecimal digits only - for (let i = 0; i < 64; i++) { - if (!((value[i] >= 'a' && value[i] <= 'f') - || (value[i] >= 'A' && value[i] <= 'F') - || (value[i] >= '0' && value[i] <= '9'))) - return false; - } - return true; - } - - return (value.length >= 8 && value.length <= 63); - }, - - _validateStaticWep: function(secret) { - let value = secret.value; - if (secret.wep_key_type == NetworkManager.WepKeyType.KEY) { - if (value.length == 10 || value.length == 26) { - for (let i = 0; i < value.length; i++) { - if (!((value[i] >= 'a' && value[i] <= 'f') - || (value[i] >= 'A' && value[i] <= 'F') - || (value[i] >= '0' && value[i] <= '9'))) - return false; - } - } else if (value.length == 5 || value.length == 13) { - for (let i = 0; i < value.length; i++) { - if (!((value[i] >= 'a' && value[i] <= 'z') - || (value[i] >= 'A' && value[i] <= 'Z'))) - return false; - } - } else - return false; - } else if (secret.wep_key_type == NetworkManager.WepKeyType.PASSPHRASE) { - if (value.length < 0 || value.length > 64) - return false; - } - return true; - }, - - _getWirelessSecrets: function(secrets, wirelessSetting) { - let wirelessSecuritySetting = this._connection.get_setting_wireless_security(); - switch (wirelessSecuritySetting.key_mgmt) { - // First the easy ones - case 'wpa-none': - case 'wpa-psk': - secrets.push({ label: _("Password: "), key: 'psk', - value: wirelessSecuritySetting.psk || '', - validate: this._validateWpaPsk, password: true }); - break; - case 'none': // static WEP - secrets.push({ label: _("Key: "), key: 'wep-key' + wirelessSecuritySetting.wep_tx_keyidx, - value: wirelessSecuritySetting.get_wep_key(wirelessSecuritySetting.wep_tx_keyidx) || '', - wep_key_type: wirelessSecuritySetting.wep_key_type, - validate: this._validateStaticWep, password: true }); - break; - case 'ieee8021x': - if (wirelessSecuritySetting.auth_alg == 'leap') // Cisco LEAP - secrets.push({ label: _("Password: "), key: 'leap-password', - value: wirelessSecuritySetting.leap_password || '', password: true }); - else // Dynamic (IEEE 802.1x) WEP - this._get8021xSecrets(secrets); - break; - case 'wpa-eap': - this._get8021xSecrets(secrets); - break; - default: - log('Invalid wireless key management: ' + wirelessSecuritySetting.key_mgmt); - } - }, - - _get8021xSecrets: function(secrets) { - let ieee8021xSetting = this._connection.get_setting_802_1x(); - let phase2method; - - switch (ieee8021xSetting.get_eap_method(0)) { - case 'md5': - case 'leap': - case 'ttls': - case 'peap': - // TTLS and PEAP are actually much more complicated, but this complication - // is not visible here since we only care about phase2 authentication - // (and don't even care of which one) - secrets.push({ label: _("Username: "), key: null, - value: ieee8021xSetting.identity || '', password: false }); - secrets.push({ label: _("Password: "), key: 'password', - value: ieee8021xSetting.password || '', password: true }); - break; - case 'tls': - secrets.push({ label: _("Identity: "), key: null, - value: ieee8021xSetting.identity || '', password: false }); - secrets.push({ label: _("Private key password: "), key: 'private-key-password', - value: ieee8021xSetting.private_key_password || '', password: true }); - break; - default: - log('Invalid EAP/IEEE802.1x method: ' + ieee8021xSetting.get_eap_method(0)); - } - }, - - _getPPPoESecrets: function(secrets) { - let pppoeSetting = this._connection.get_setting_pppoe(); - secrets.push({ label: _("Username: "), key: 'username', - value: pppoeSetting.username || '', password: false }); - secrets.push({ label: _("Service: "), key: 'service', - value: pppoeSetting.service || '', password: false }); - secrets.push({ label: _("Password: "), key: 'password', - value: pppoeSetting.password || '', password: true }); - }, - - _getMobileSecrets: function(secrets, connectionType) { - let setting; - if (connectionType == 'bluetooth') - setting = this._connection.get_setting_cdma() || this._connection.get_setting_gsm(); - else - setting = this._connection.get_setting_by_name(connectionType); - secrets.push({ label: _("Password: "), key: 'password', - value: setting.value || '', password: true }); - }, - - _getContent: function() { - let connectionSetting = this._connection.get_setting_connection(); - let connectionType = connectionSetting.get_connection_type(); - let wirelessSetting; - let ssid; - - let content = { }; - content.secrets = [ ]; - - switch (connectionType) { - case '802-11-wireless': - wirelessSetting = this._connection.get_setting_wireless(); - ssid = NetworkManager.utils_ssid_to_utf8(wirelessSetting.get_ssid()); - content.title = _("Authentication required by wireless network"); - content.message = _("Passwords or encryption keys are required to access the wireless network '%s'.").format(ssid); - this._getWirelessSecrets(content.secrets, wirelessSetting); - break; - case '802-3-ethernet': - content.title = _("Wired 802.1X authentication"); - content.message = null; - content.secrets.push({ label: _("Network name: "), key: null, - value: connectionSetting.get_id(), password: false }); - this._get8021xSecrets(content.secrets); - break; - case 'pppoe': - content.title = _("DSL authentication"); - content.message = null; - this._getPPPoESecrets(content.secrets); - break; - case 'gsm': - if (this._hints.indexOf('pin') != -1) { - let gsmSetting = this._connection.get_setting_gsm(); - content.title = _("PIN code required"); - content.message = _("PIN code is needed for the mobile broadband device"); - content.secrets.push({ label: _("PIN: "), key: 'pin', - value: gsmSetting.pin || '', password: true }); - } - // fall through - case 'cdma': - case 'bluetooth': - content.title = _("Mobile broadband network password"); - content.message = _("A password is required to connect to '%s'.").format(connectionSetting.get_id()); - this._getMobileSecrets(content.secrets, connectionType); - break; - default: - log('Invalid connection type: ' + connectionType); - }; - - return content; - } -}); - -const VPNRequestHandler = new Lang.Class({ - Name: 'VPNRequestHandler', - - _init: function(agent, requestId, authHelper, serviceType, connection, hints, flags) { - this._agent = agent; - this._requestId = requestId; - this._connection = connection; - this._pluginOutBuffer = []; - this._title = null; - this._description = null; - this._content = [ ]; - this._shellDialog = null; - - let connectionSetting = connection.get_setting_connection(); - - let argv = [ authHelper.fileName, - '-u', connectionSetting.uuid, - '-n', connectionSetting.id, - '-s', serviceType - ]; - if (authHelper.externalUIMode) - argv.push('--external-ui-mode'); - if (flags & NMClient.SecretAgentGetSecretsFlags.ALLOW_INTERACTION) - argv.push('-i'); - if (flags & NMClient.SecretAgentGetSecretsFlags.REQUEST_NEW) - argv.push('-r'); - - this._newStylePlugin = authHelper.externalUIMode; - - try { - let [success, pid, stdin, stdout, stderr] = - GLib.spawn_async_with_pipes(null, /* pwd */ - argv, - null, /* envp */ - GLib.SpawnFlags.DO_NOT_REAP_CHILD, - null /* child_setup */); - - this._childPid = pid; - this._stdin = new Gio.UnixOutputStream({ fd: stdin, close_fd: true }); - this._stdout = new Gio.UnixInputStream({ fd: stdout, close_fd: true }); - // We need this one too, even if don't actually care of what the process - // has to say on stderr, because otherwise the fd opened by g_spawn_async_with_pipes - // is kept open indefinitely - let stderrStream = new Gio.UnixInputStream({ fd: stderr, close_fd: true }); - stderrStream.close(null); - this._dataStdout = new Gio.DataInputStream({ base_stream: this._stdout }); - - if (this._newStylePlugin) - this._readStdoutNewStyle(); - else - this._readStdoutOldStyle(); - - this._childWatch = GLib.child_watch_add(GLib.PRIORITY_DEFAULT, pid, - Lang.bind(this, this._vpnChildFinished)); - - this._writeConnection(); - } catch(e) { - logError(e, 'error while spawning VPN auth helper'); - - this._agent.respond(requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR); - } - }, - - cancel: function() { - if (this._newStylePlugin && this._shellDialog) { - this._shellDialog.close(global.get_current_time()); - this._shellDialog.destroy(); - } else { - try { - this._stdin.write('QUIT\n\n', null); - } catch(e) { /* ignore broken pipe errors */ } - } - - this.destroy(); - }, - - destroy: function() { - if (this._destroyed) - return; - - GLib.source_remove(this._childWatch); - - this._stdin.close(null); - // Stdout is closed when we finish reading from it - - this._destroyed = true; - }, - - _vpnChildFinished: function(pid, status, requestObj) { - if (this._newStylePlugin) { - // For new style plugin, all work is done in the async reading functions - // Just reap the process here - return; - } - - let [exited, exitStatus] = Shell.util_wifexited(status); - - if (exited) { - if (exitStatus != 0) - this._agent.respond(this._requestId, Shell.NetworkAgentResponse.USER_CANCELED); - else - this._agent.respond(this._requestId, Shell.NetworkAgentResponse.CONFIRMED); - } else - this._agent.respond(this._requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR); - - this.destroy(); - }, - - _vpnChildProcessLineOldStyle: function(line) { - if (this._previousLine != undefined) { - // Two consecutive newlines mean that the child should be closed - // (the actual newlines are eaten by Gio.DataInputStream) - // Send a termination message - if (line == '' && this._previousLine == '') { - try { - this._stdin.write('QUIT\n\n', null); - } catch(e) { /* ignore broken pipe errors */ } - } else { - this._agent.set_password(this._requestId, this._previousLine, line); - this._previousLine = undefined; - } - } else { - this._previousLine = line; - } - }, - - _readStdoutOldStyle: function() { - this._dataStdout.read_line_async(GLib.PRIORITY_DEFAULT, null, Lang.bind(this, function(stream, result) { - let [line, len] = this._dataStdout.read_line_finish_utf8(result); - - if (line == null) { - // end of file - this._stdout.close(null); - return; - } - - this._vpnChildProcessLineOldStyle(line); - - // try to read more! - this._readStdoutOldStyle(); - })); - }, - - _readStdoutNewStyle: function() { - this._dataStdout.fill_async(-1, GLib.PRIORITY_DEFAULT, null, Lang.bind(this, function(stream, result) { - let cnt = this._dataStdout.fill_finish(result); - - if (cnt == 0) { - // end of file - this._showNewStyleDialog(); - - this._stdout.close(null); - return; - } - - // Try to read more - this._dataStdout.set_buffer_size(2 * this._dataStdout.get_buffer_size()); - this._readStdoutNewStyle(); - })); - }, - - _showNewStyleDialog: function() { - let keyfile = new GLib.KeyFile(); - let contentOverride; - - try { - let data = this._dataStdout.peek_buffer(); - keyfile.load_from_data(data.toString(), data.length, - GLib.KeyFileFlags.NONE); - - if (keyfile.get_integer(VPN_UI_GROUP, 'Version') != 2) - throw new Error('Invalid plugin keyfile version, is %d'); - - contentOverride = { title: keyfile.get_string(VPN_UI_GROUP, 'Title'), - message: keyfile.get_string(VPN_UI_GROUP, 'Description'), - secrets: [] }; - - let [groups, len] = keyfile.get_groups(); - for (let i = 0; i < groups.length; i++) { - if (groups[i] == VPN_UI_GROUP) - continue; - - let value = keyfile.get_string(groups[i], 'Value'); - let shouldAsk = keyfile.get_boolean(groups[i], 'ShouldAsk'); - - if (shouldAsk) { - contentOverride.secrets.push({ label: keyfile.get_string(groups[i], 'Label'), - key: groups[i], - value: value, - password: keyfile.get_boolean(groups[i], 'IsSecret') - }); - } else { - if (!value.length) // Ignore empty secrets - continue; - - this._agent.set_password(this._requestId, groups[i], value); - } - } - } catch(e) { - logError(e, 'error while reading VPN plugin output keyfile'); - - this._agent.respond(this._requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR); - return; - } - - if (contentOverride.secrets.length) { - // Only show the dialog if we actually have something to ask - this._shellDialog = new NetworkSecretDialog(this._agent, this._requestId, this._connection, 'vpn', [], contentOverride); - this._shellDialog.open(global.get_current_time()); - } else { - this._agent.respond(this._requestId, Shell.NetworkAgentResponse.CONFIRMED); - } - }, - - _writeConnection: function() { - let vpnSetting = this._connection.get_setting_vpn(); - - try { - vpnSetting.foreach_data_item(Lang.bind(this, function(key, value) { - this._stdin.write('DATA_KEY=' + key + '\n', null); - this._stdin.write('DATA_VAL=' + (value || '') + '\n\n', null); - })); - vpnSetting.foreach_secret(Lang.bind(this, function(key, value) { - this._stdin.write('SECRET_KEY=' + key + '\n', null); - this._stdin.write('SECRET_VAL=' + (value || '') + '\n\n', null); - })); - this._stdin.write('DONE\n\n', null); - } catch(e) { - logError(e, 'internal error while writing connection to helper'); - - this._agent.respond(this._requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR); - } - }, -}); - -const NetworkAgent = new Lang.Class({ - Name: 'NetworkAgent', - - _init: function() { - this._native = new Shell.NetworkAgent({ auto_register: true, - identifier: 'org.gnome.Shell.NetworkAgent' }); - - this._dialogs = { }; - this._vpnRequests = { }; - - this._native.connect('new-request', Lang.bind(this, this._newRequest)); - this._native.connect('cancel-request', Lang.bind(this, this._cancelRequest)); - }, - - _newRequest: function(agent, requestId, connection, settingName, hints, flags) { - if (settingName == 'vpn') { - this._vpnRequest(requestId, connection, hints, flags); - return; - } - - let dialog = new NetworkSecretDialog(agent, requestId, connection, settingName, hints); - dialog.connect('destroy', Lang.bind(this, function() { - delete this._dialogs[requestId]; - })); - this._dialogs[requestId] = dialog; - dialog.open(global.get_current_time()); - }, - - _cancelRequest: function(agent, requestId) { - if (this._dialogs[requestId]) { - this._dialogs[requestId].close(global.get_current_time()); - this._dialogs[requestId].destroy(); - delete this._dialogs[requestId]; - } else if (this._vpnRequests[requestId]) { - this._vpnRequests[requestId].cancel(); - delete this._vpnRequests[requestId]; - } - }, - - _vpnRequest: function(requestId, connection, hints, flags) { - let vpnSetting = connection.get_setting_vpn(); - let serviceType = vpnSetting.service_type; - - this._buildVPNServiceCache(); - - let binary = this._vpnBinaries[serviceType]; - if (!binary) { - log('Invalid VPN service type (cannot find authentication binary)'); - - /* cancel the auth process */ - this._native.respond(requestId, Shell.NetworkAgentResponse.INTERNAL_ERROR); - return; - } - - this._vpnRequests[requestId] = new VPNRequestHandler(this._native, requestId, binary, serviceType, connection, hints, flags); - }, - - _buildVPNServiceCache: function() { - if (this._vpnCacheBuilt) - return; - - this._vpnCacheBuilt = true; - this._vpnBinaries = { }; - - let dir = Gio.file_new_for_path(GLib.build_filenamev([Config.SYSCONFDIR, 'NetworkManager/VPN'])); - try { - let fileEnum = dir.enumerate_children('standard::name', Gio.FileQueryInfoFlags.NONE, null); - let info; - - while ((info = fileEnum.next_file(null))) { - let name = info.get_name(); - if (name.substr(-5) != '.name') - continue; - - try { - let keyfile = new GLib.KeyFile(); - keyfile.load_from_file(dir.get_child(name).get_path(), GLib.KeyFileFlags.NONE); - let service = keyfile.get_string('VPN Connection', 'service'); - let binary = keyfile.get_string('GNOME', 'auth-dialog'); - let externalUIMode = false; - try { - externalUIMode = keyfile.get_boolean('GNOME', 'supports-external-ui-mode'); - } catch(e) { } // ignore errors if key does not exist - let path = binary; - if (!GLib.path_is_absolute(path)) { - path = GLib.build_filenamev([Config.LIBEXECDIR, path]); - } - - if (GLib.file_test(path, GLib.FileTest.IS_EXECUTABLE)) - this._vpnBinaries[service] = { fileName: path, externalUIMode: externalUIMode }; - else - throw new Error('VPN plugin at %s is not executable'.format(path)); - } catch(e) { - log('Error \'%s\' while processing VPN keyfile \'%s\''. - format(e.message, dir.get_child(name).get_path())); - continue; - } - } - } catch(e) { - logError(e, 'error while enumerating VPN auth helpers'); - } - } -}); diff --git a/js/ui/polkitAuthenticationAgent.js b/js/ui/polkitAuthenticationAgent.js deleted file mode 100644 index 92e4c6aa0..000000000 --- a/js/ui/polkitAuthenticationAgent.js +++ /dev/null @@ -1,403 +0,0 @@ -// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- -/* - * Copyright 2010 Red Hat, Inc - * - * This program is free software; you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation; either version 2, or (at your option) - * any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA - * 02111-1307, USA. -* - * Author: David Zeuthen - */ - -const Lang = imports.lang; -const Signals = imports.signals; -const Shell = imports.gi.Shell; -const AccountsService = imports.gi.AccountsService; -const Clutter = imports.gi.Clutter; -const St = imports.gi.St; -const Pango = imports.gi.Pango; -const GLib = imports.gi.GLib; -const Gio = imports.gi.Gio; -const Mainloop = imports.mainloop; -const Polkit = imports.gi.Polkit; -const PolkitAgent = imports.gi.PolkitAgent; - -const ModalDialog = imports.ui.modalDialog; -const ShellEntry = imports.ui.shellEntry; -const UserMenu = imports.ui.userMenu; - -const DIALOG_ICON_SIZE = 48; - -const AuthenticationDialog = new Lang.Class({ - Name: 'AuthenticationDialog', - Extends: ModalDialog.ModalDialog, - - _init: function(actionId, message, cookie, userNames) { - this.parent({ styleClass: 'prompt-dialog' }); - - this.actionId = actionId; - this.message = message; - this.userNames = userNames; - this._wasDismissed = false; - this._completed = false; - - let mainContentBox = new St.BoxLayout({ style_class: 'prompt-dialog-main-layout', - vertical: false }); - this.contentLayout.add(mainContentBox, - { x_fill: true, - y_fill: true }); - - let icon = new St.Icon({ icon_name: 'dialog-password-symbolic' }); - mainContentBox.add(icon, - { x_fill: true, - y_fill: false, - x_align: St.Align.END, - y_align: St.Align.START }); - - let messageBox = new St.BoxLayout({ style_class: 'prompt-dialog-message-layout', - vertical: true }); - mainContentBox.add(messageBox, - { y_align: St.Align.START }); - - this._subjectLabel = new St.Label({ style_class: 'prompt-dialog-headline', - text: _("Authentication Required") }); - - messageBox.add(this._subjectLabel, - { y_fill: false, - y_align: St.Align.START }); - - this._descriptionLabel = new St.Label({ style_class: 'prompt-dialog-description', - text: message }); - this._descriptionLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; - this._descriptionLabel.clutter_text.line_wrap = true; - - messageBox.add(this._descriptionLabel, - { y_fill: true, - y_align: St.Align.START }); - - if (userNames.length > 1) { - log('polkitAuthenticationAgent: Received ' + userNames.length + - ' identities that can be used for authentication. Only ' + - 'considering one.'); - } - - let userName = GLib.get_user_name(); - if (userNames.indexOf(userName) < 0) - userName = 'root'; - if (userNames.indexOf(userName) < 0) - userName = userNames[0]; - - this._user = AccountsService.UserManager.get_default().get_user(userName); - let userRealName = this._user.get_real_name() - this._userLoadedId = this._user.connect('notify::is_loaded', - Lang.bind(this, this._onUserChanged)); - this._userChangedId = this._user.connect('changed', - Lang.bind(this, this._onUserChanged)); - - // Special case 'root' - let userIsRoot = false; - if (userName == 'root') { - userIsRoot = true; - userRealName = _("Administrator"); - } - - if (userIsRoot) { - let userLabel = new St.Label(({ style_class: 'polkit-dialog-user-root-label', - text: userRealName })); - messageBox.add(userLabel); - } else { - let userBox = new St.BoxLayout({ style_class: 'polkit-dialog-user-layout', - vertical: false }); - messageBox.add(userBox); - this._userAvatar = new UserMenu.UserAvatarWidget(this._user, - { iconSize: DIALOG_ICON_SIZE, - styleClass: 'polkit-dialog-user-icon' }); - this._userAvatar.actor.hide(); - userBox.add(this._userAvatar.actor, - { x_fill: true, - y_fill: false, - x_align: St.Align.END, - y_align: St.Align.START }); - let userLabel = new St.Label(({ style_class: 'polkit-dialog-user-label', - text: userRealName })); - userBox.add(userLabel, - { x_fill: true, - y_fill: false, - x_align: St.Align.END, - y_align: St.Align.MIDDLE }); - } - - this._onUserChanged(); - - this._passwordBox = new St.BoxLayout({ vertical: false, style_class: 'prompt-dialog-password-box' }); - messageBox.add(this._passwordBox); - this._passwordLabel = new St.Label(({ style_class: 'prompt-dialog-password-label' })); - this._passwordBox.add(this._passwordLabel, { y_fill: false, y_align: St.Align.MIDDLE }); - this._passwordEntry = new St.Entry({ style_class: 'prompt-dialog-password-entry', - text: "", - can_focus: true}); - ShellEntry.addContextMenu(this._passwordEntry, { isPassword: true }); - this._passwordEntry.clutter_text.connect('activate', Lang.bind(this, this._onEntryActivate)); - this._passwordBox.add(this._passwordEntry, - { expand: true }); - this.setInitialKeyFocus(this._passwordEntry); - this._passwordBox.hide(); - - this._errorMessageLabel = new St.Label({ style_class: 'prompt-dialog-error-label' }); - this._errorMessageLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; - this._errorMessageLabel.clutter_text.line_wrap = true; - messageBox.add(this._errorMessageLabel); - this._errorMessageLabel.hide(); - - this._infoMessageLabel = new St.Label({ style_class: 'prompt-dialog-info-label' }); - this._infoMessageLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; - this._infoMessageLabel.clutter_text.line_wrap = true; - messageBox.add(this._infoMessageLabel); - this._infoMessageLabel.hide(); - - /* text is intentionally non-blank otherwise the height is not the same as for - * infoMessage and errorMessageLabel - but it is still invisible because - * gnome-shell.css sets the color to be transparent - */ - this._nullMessageLabel = new St.Label({ style_class: 'prompt-dialog-null-label', - text: 'abc'}); - this._nullMessageLabel.add_style_class_name('hidden'); - this._nullMessageLabel.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; - this._nullMessageLabel.clutter_text.line_wrap = true; - messageBox.add(this._nullMessageLabel); - this._nullMessageLabel.show(); - - this.setButtons([{ label: _("Cancel"), - action: Lang.bind(this, this.cancel), - key: Clutter.Escape - }, - { label: _("Authenticate"), - action: Lang.bind(this, this._onAuthenticateButtonPressed), - default: true - }]); - - this._doneEmitted = false; - - this._identityToAuth = Polkit.UnixUser.new_for_name(userName); - this._cookie = cookie; - - this._session = new PolkitAgent.Session({ identity: this._identityToAuth, - cookie: this._cookie }); - this._session.connect('completed', Lang.bind(this, this._onSessionCompleted)); - this._session.connect('request', Lang.bind(this, this._onSessionRequest)); - this._session.connect('show-error', Lang.bind(this, this._onSessionShowError)); - this._session.connect('show-info', Lang.bind(this, this._onSessionShowInfo)); - }, - - startAuthentication: function() { - this._session.initiate(); - }, - - _ensureOpen: function() { - // NOTE: ModalDialog.open() is safe to call if the dialog is - // already open - it just returns true without side-effects - if (!this.open(global.get_current_time())) { - // This can fail if e.g. unable to get input grab - // - // In an ideal world this wouldn't happen (because the - // Shell is in complete control of the session) but that's - // just not how things work right now. - // - // One way to make this happen is by running 'sleep 3; - // pkexec bash' and then opening a popup menu. - // - // We could add retrying if this turns out to be a problem - - log('polkitAuthenticationAgent: Failed to show modal dialog.' + - ' Dismissing authentication request for action-id ' + this.actionId + - ' cookie ' + this._cookie); - this._emitDone(false, true); - } - }, - - _emitDone: function(keepVisible, dismissed) { - if (!this._doneEmitted) { - this._doneEmitted = true; - this.emit('done', keepVisible, dismissed); - } - }, - - _onEntryActivate: function() { - let response = this._passwordEntry.get_text(); - this._session.response(response); - // When the user responds, dismiss already shown info and - // error texts (if any) - this._errorMessageLabel.hide(); - this._infoMessageLabel.hide(); - this._nullMessageLabel.show(); - }, - - _onAuthenticateButtonPressed: function() { - this._onEntryActivate(); - }, - - _onSessionCompleted: function(session, gainedAuthorization) { - if (this._completed) - return; - - this._completed = true; - - if (!gainedAuthorization) { - /* Unless we are showing an existing error message from the PAM - * module (the PAM module could be reporting the authentication - * error providing authentication-method specific information), - * show "Sorry, that didn't work. Please try again." - */ - if (!this._errorMessageLabel.visible && !this._wasDismissed) { - /* Translators: "that didn't work" refers to the fact that the - * requested authentication was not gained; this can happen - * because of an authentication error (like invalid password), - * for instance. */ - this._errorMessageLabel.set_text(_("Sorry, that didn\'t work. Please try again.")); - this._errorMessageLabel.show(); - this._infoMessageLabel.hide(); - this._nullMessageLabel.hide(); - } - } - this._emitDone(!gainedAuthorization, false); - }, - - _onSessionRequest: function(session, request, echo_on) { - // Cheap localization trick - if (request == 'Password:' || request == 'Password: ') - this._passwordLabel.set_text(_("Password:")); - else - this._passwordLabel.set_text(request); - - if (echo_on) - this._passwordEntry.clutter_text.set_password_char(''); - else - this._passwordEntry.clutter_text.set_password_char('\u25cf'); // ● U+25CF BLACK CIRCLE - - this._passwordBox.show(); - this._passwordEntry.set_text(''); - this._passwordEntry.grab_key_focus(); - this._ensureOpen(); - }, - - _onSessionShowError: function(session, text) { - this._passwordEntry.set_text(''); - this._errorMessageLabel.set_text(text); - this._errorMessageLabel.show(); - this._infoMessageLabel.hide(); - this._nullMessageLabel.hide(); - this._ensureOpen(); - }, - - _onSessionShowInfo: function(session, text) { - this._passwordEntry.set_text(''); - this._infoMessageLabel.set_text(text); - this._infoMessageLabel.show(); - this._errorMessageLabel.hide(); - this._nullMessageLabel.hide(); - this._ensureOpen(); - }, - - destroySession: function() { - if (this._session) { - if (!this._completed) - this._session.cancel(); - this._session = null; - } - }, - - _onUserChanged: function() { - if (this._user.is_loaded) { - this._userAvatar.update(); - this._userAvatar.actor.show(); - } - }, - - cancel: function() { - this._wasDismissed = true; - this.close(global.get_current_time()); - this._emitDone(false, true); - }, -}); -Signals.addSignalMethods(AuthenticationDialog.prototype); - -const AuthenticationAgent = new Lang.Class({ - Name: 'AuthenticationAgent', - - _init: function() { - this._native = new Shell.PolkitAuthenticationAgent(); - this._native.connect('initiate', Lang.bind(this, this._onInitiate)); - this._native.connect('cancel', Lang.bind(this, this._onCancel)); - this._currentDialog = null; - this._isCompleting = false; - }, - - _onInitiate: function(nativeAgent, actionId, message, iconName, cookie, userNames) { - this._currentDialog = new AuthenticationDialog(actionId, message, cookie, userNames); - - // We actually don't want to open the dialog until we know for - // sure that we're going to interact with the user. For - // example, if the password for the identity to auth is blank - // (which it will be on a live CD) then there will be no - // conversation at all... of course, we don't *know* that - // until we actually try it. - // - // See https://bugzilla.gnome.org/show_bug.cgi?id=643062 for more - // discussion. - - this._currentDialog.connect('done', Lang.bind(this, this._onDialogDone)); - this._currentDialog.startAuthentication(); - }, - - _onCancel: function(nativeAgent) { - this._completeRequest(false, false); - }, - - _onDialogDone: function(dialog, keepVisible, dismissed) { - this._completeRequest(keepVisible, dismissed); - }, - - _reallyCompleteRequest: function(dismissed) { - this._currentDialog.close(); - this._currentDialog.destroySession(); - this._currentDialog = null; - this._isCompleting = false; - - this._native.complete(dismissed) - }, - - _completeRequest: function(keepVisible, wasDismissed) { - if (this._isCompleting) - return; - - this._isCompleting = true; - - if (keepVisible) { - // Give the user 2 seconds to read 'Authentication Failure' before - // dismissing the dialog - Mainloop.timeout_add(2000, - Lang.bind(this, - function() { - this._reallyCompleteRequest(wasDismissed); - return false; - })); - } else { - this._reallyCompleteRequest(wasDismissed); - } - } -}); - -function init() { - let agent = new AuthenticationAgent(); -} diff --git a/js/ui/sessionMode.js b/js/ui/sessionMode.js index 09c05608e..572864312 100644 --- a/js/ui/sessionMode.js +++ b/js/ui/sessionMode.js @@ -17,8 +17,8 @@ const _modes = { hasRunDialog: false, hasWorkspaces: false, hasWindows: false, - createSession: Main.createGDMSession, createUnlockDialog: Main.createGDMLoginDialog, + components: [], panel: { left: [], center: ['dateMenu'], @@ -36,6 +36,7 @@ const _modes = { hasRunDialog: false, hasWorkspaces: false, hasWindows: false, + components: ['networkAgent', 'polkitAgent', 'telepathyClient'], panel: { left: ['userMenu'], center: [], @@ -50,7 +51,7 @@ const _modes = { allowKeybindingsWhenModal: false, hasRunDialog: false, hasWorkspaces: false, - createSession: Main.createInitialSetupSession, + components: ['keyring'], panel: { left: [], center: ['dateMenu'], @@ -66,8 +67,9 @@ const _modes = { hasRunDialog: true, hasWorkspaces: true, hasWindows: true, - createSession: Main.createUserSession, createUnlockDialog: Main.createSessionUnlockDialog, + components: ['networkAgent', 'polkitAgent', 'telepathyClient', + 'keyring', 'recorder', 'autorunManager', 'automountManager'], panel: { left: ['activities', 'appMenu'], center: ['dateMenu'], @@ -112,8 +114,6 @@ const SessionMode = new Lang.Class({ let params = _modes[this.currentMode]; params = Params.parse(params, _modes[DEFAULT_MODE]); - this._createSession = params.createSession; - delete params.createSession; this._createUnlockDialog = params.createUnlockDialog; delete params.createUnlockDialog; @@ -121,11 +121,6 @@ const SessionMode = new Lang.Class({ this.emit('updated'); }, - createSession: function() { - if (this._createSession) - this._createSession(); - }, - createUnlockDialog: function() { if (this._createUnlockDialog) return this._createUnlockDialog.apply(this, arguments); diff --git a/js/ui/telepathyClient.js b/js/ui/telepathyClient.js deleted file mode 100644 index 08218d7a7..000000000 --- a/js/ui/telepathyClient.js +++ /dev/null @@ -1,1401 +0,0 @@ -// -*- mode: js; js-indent-level: 4; indent-tabs-mode: nil -*- - -const Gio = imports.gi.Gio; -const GLib = imports.gi.GLib; -const Lang = imports.lang; -const Mainloop = imports.mainloop; -const Shell = imports.gi.Shell; -const Signals = imports.signals; -const St = imports.gi.St; -const Tpl = imports.gi.TelepathyLogger; -const Tp = imports.gi.TelepathyGLib; - -const History = imports.misc.history; -const Params = imports.misc.params; -const Main = imports.ui.main; -const MessageTray = imports.ui.messageTray; - - -// See Notification.appendMessage -const SCROLLBACK_IMMEDIATE_TIME = 60; // 1 minute -const SCROLLBACK_RECENT_TIME = 15 * 60; // 15 minutes -const SCROLLBACK_RECENT_LENGTH = 20; -const SCROLLBACK_IDLE_LENGTH = 5; - -// See Source._displayPendingMessages -const SCROLLBACK_HISTORY_LINES = 10; - -// See Notification._onEntryChanged -const COMPOSING_STOP_TIMEOUT = 5; - -const NotificationDirection = { - SENT: 'chat-sent', - RECEIVED: 'chat-received' -}; - -function makeMessageFromTpMessage(tpMessage, direction) { - let [text, flags] = tpMessage.to_text(); - - let timestamp = tpMessage.get_sent_timestamp(); - if (timestamp == 0) - timestamp = tpMessage.get_received_timestamp(); - - return { - messageType: tpMessage.get_message_type(), - text: text, - sender: tpMessage.sender.alias, - timestamp: timestamp, - direction: direction - }; -} - - -function makeMessageFromTplEvent(event) { - let sent = event.get_sender().get_entity_type() == Tpl.EntityType.SELF; - let direction = sent ? NotificationDirection.SENT : NotificationDirection.RECEIVED; - - return { - messageType: event.get_message_type(), - text: event.get_message(), - sender: event.get_sender().get_alias(), - timestamp: event.get_timestamp(), - direction: direction - }; -} - -const Client = new Lang.Class({ - Name: 'Client', - - _init : function() { - // channel path -> ChatSource - this._chatSources = {}; - this._chatState = Tp.ChannelChatState.ACTIVE; - - // account path -> AccountNotification - this._accountNotifications = {}; - - // Define features we want - this._accountManager = Tp.AccountManager.dup(); - let factory = this._accountManager.get_factory(); - factory.add_account_features([Tp.Account.get_feature_quark_connection()]); - factory.add_connection_features([Tp.Connection.get_feature_quark_contact_list()]); - factory.add_channel_features([Tp.Channel.get_feature_quark_contacts()]); - factory.add_contact_features([Tp.ContactFeature.ALIAS, - Tp.ContactFeature.AVATAR_DATA, - Tp.ContactFeature.PRESENCE, - Tp.ContactFeature.SUBSCRIPTION_STATES]); - - // Set up a SimpleObserver, which will call _observeChannels whenever a - // channel matching its filters is detected. - // The second argument, recover, means _observeChannels will be run - // for any existing channel as well. - this._tpClient = new Shell.TpClient({ 'account-manager': this._accountManager, - 'name': 'GnomeShell', - 'uniquify-name': true }) - this._tpClient.set_observe_channels_func( - Lang.bind(this, this._observeChannels)); - this._tpClient.set_approve_channels_func( - Lang.bind(this, this._approveChannels)); - this._tpClient.set_handle_channels_func( - Lang.bind(this, this._handleChannels)); - - // Workaround for gjs not supporting GPtrArray in signals. - // See BGO bug #653941 for context. - this._tpClient.set_contact_list_changed_func( - Lang.bind(this, this._contactListChanged)); - - // Allow other clients (such as Empathy) to pre-empt our channels if - // needed - this._tpClient.set_delegated_channels_callback( - Lang.bind(this, this._delegatedChannelsCb)); - - try { - this._tpClient.register(); - } catch (e) { - throw new Error('Couldn\'t register Telepathy client. Error: \n' + e); - } - - // Watch subscription requests and connection errors - this._subscriptionSource = null; - this._accountSource = null; - - this._accountManager.connect('account-validity-changed', - Lang.bind(this, this._accountValidityChanged)); - - this._accountManager.prepare_async(null, Lang.bind(this, this._accountManagerPrepared)); - }, - - _observeChannels: function(observer, account, conn, channels, - dispatchOp, requests, context) { - let len = channels.length; - for (let i = 0; i < len; i++) { - let channel = channels[i]; - let [targetHandle, targetHandleType] = channel.get_handle(); - - if (channel.get_invalidated()) - continue; - - /* Only observe contact text channels */ - if ((!(channel instanceof Tp.TextChannel)) || - targetHandleType != Tp.HandleType.CONTACT) - continue; - - this._createChatSource(account, conn, channel, channel.get_target_contact()); - } - - context.accept(); - }, - - _createChatSource: function(account, conn, channel, contact) { - if (this._chatSources[channel.get_object_path()]) - return; - - let source = new ChatSource(account, conn, channel, contact, this._tpClient); - - this._chatSources[channel.get_object_path()] = source; - source.connect('destroy', Lang.bind(this, - function() { - if (this._tpClient.is_handling_channel(channel)) { - // The chat box has been destroyed so it can't - // handle the channel any more. - channel.close_async(function(src, result) { - channel.close_finish(result); - }); - } - - delete this._chatSources[channel.get_object_path()]; - })); - }, - - _handleChannels: function(handler, account, conn, channels, - requests, user_action_time, context) { - this._handlingChannels(account, conn, channels, true); - context.accept(); - }, - - _handlingChannels: function(account, conn, channels, notify) { - let len = channels.length; - for (let i = 0; i < len; i++) { - let channel = channels[i]; - - // We can only handle text channel, so close any other channel - if (!(channel instanceof Tp.TextChannel)) { - channel.close_async(null); - continue; - } - - if (channel.get_invalidated()) - continue; - - // 'notify' will be true when coming from an actual HandleChannels - // call, and not when from a successful Claim call. The point is - // we don't want to notify for a channel we just claimed which - // has no new messages (for example, a new channel which only has - // a delivery notification). We rely on _displayPendingMessages() - // and _messageReceived() to notify for new messages. - - // But we should still notify from HandleChannels because the - // Telepathy spec states that handlers must foreground channels - // in HandleChannels calls which are already being handled. - - if (notify && this._tpClient.is_handling_channel(channel)) { - // We are already handling the channel, display the source - let source = this._chatSources[channel.get_object_path()]; - if (source) - source.notify(); - } - } - }, - - _displayRoomInvitation: function(conn, channel, dispatchOp, context) { - // We can only approve the rooms if we have been invited to it - let selfContact = channel.group_get_self_contact(); - if (selfContact == null) { - context.fail(new Tp.Error({ code: Tp.Error.INVALID_ARGUMENT, - message: 'Not invited to the room' })); - return; - } - - let [invited, inviter, reason, msg] = channel.group_get_local_pending_contact_info(selfContact); - if (!invited) { - context.fail(new Tp.Error({ code: Tp.Error.INVALID_ARGUMENT, - message: 'Not invited to the room' })); - return; - } - - // FIXME: We don't have a 'chat room' icon (bgo #653737) use - // system-users for now as Empathy does. - let source = new ApproverSource(dispatchOp, _("Invitation"), - Gio.icon_new_for_string('system-users')); - Main.messageTray.add(source); - - let notif = new RoomInviteNotification(source, dispatchOp, channel, inviter); - source.notify(notif); - context.accept(); - }, - - _approveChannels: function(approver, account, conn, channels, - dispatchOp, context) { - let channel = channels[0]; - let chanType = channel.get_channel_type(); - - if (channel.get_invalidated()) { - context.fail(new Tp.Error({ code: Tp.Error.INVALID_ARGUMENT, - message: 'Channel is invalidated' })); - return; - } - - if (chanType == Tp.IFACE_CHANNEL_TYPE_TEXT) - this._approveTextChannel(account, conn, channel, dispatchOp, context); - else if (chanType == Tp.IFACE_CHANNEL_TYPE_CALL) - this._approveCall(account, conn, channel, dispatchOp, context); - else if (chanType == Tp.IFACE_CHANNEL_TYPE_FILE_TRANSFER) - this._approveFileTransfer(account, conn, channel, dispatchOp, context); - else - context.fail(new Tp.Error({ code: Tp.Error.INVALID_ARGUMENT, - message: 'Unsupported channel type' })); - }, - - _approveTextChannel: function(account, conn, channel, dispatchOp, context) { - let [targetHandle, targetHandleType] = channel.get_handle(); - - if (targetHandleType == Tp.HandleType.CONTACT) { - // Approve private text channels right away as we are going to handle it - dispatchOp.claim_with_async(this._tpClient, - Lang.bind(this, function(dispatchOp, result) { - try { - dispatchOp.claim_with_finish(result); - this._handlingChannels(account, conn, [channel], false); - } catch (err) { - throw new Error('Failed to Claim channel: ' + err); - }})); - - context.accept(); - } else { - this._displayRoomInvitation(conn, channel, dispatchOp, context); - } - }, - - _approveCall: function(account, conn, channel, dispatchOp, context) { - let isVideo = false; - - let props = channel.borrow_immutable_properties(); - - if (props[Tp.PROP_CHANNEL_TYPE_CALL_INITIAL_VIDEO]) - isVideo = true; - - // We got the TpContact - let source = new ApproverSource(dispatchOp, _("Call"), isVideo ? - Gio.icon_new_for_string('camera-web') : - Gio.icon_new_for_string('audio-input-microphone')); - Main.messageTray.add(source); - - let notif = new AudioVideoNotification(source, dispatchOp, channel, - channel.get_target_contact(), isVideo); - source.notify(notif); - context.accept(); - }, - - _approveFileTransfer: function(account, conn, channel, dispatchOp, context) { - // Use the icon of the file being transferred - let gicon = Gio.content_type_get_icon(channel.get_mime_type()); - - // We got the TpContact - let source = new ApproverSource(dispatchOp, _("File Transfer"), gicon); - Main.messageTray.add(source); - - let notif = new FileTransferNotification(source, dispatchOp, channel, - channel.get_target_contact()); - source.notify(notif); - context.accept(); - }, - - _delegatedChannelsCb: function(client, channels) { - // Nothing to do as we don't make a distinction between observed and - // handled channels. - }, - - _accountManagerPrepared: function(am, result) { - am.prepare_finish(result); - - let accounts = am.get_valid_accounts(); - for (let i = 0; i < accounts.length; i++) { - this._accountValidityChanged(am, accounts[i], true); - } - }, - - _accountValidityChanged: function(am, account, valid) { - if (!valid) - return; - - // It would be better to connect to "status-changed" but we cannot. - // See discussion in https://bugzilla.gnome.org/show_bug.cgi?id=654159 - account.connect("notify::connection-status", - Lang.bind(this, this._accountConnectionStatusNotifyCb)); - - account.connect('notify::connection', - Lang.bind(this, this._connectionChanged)); - this._connectionChanged(account); - }, - - _connectionChanged: function(account) { - let conn = account.get_connection(); - if (conn == null) - return; - - this._tpClient.grab_contact_list_changed(conn); - if (conn.get_contact_list_state() == Tp.ContactListState.SUCCESS) { - this._contactListChanged(conn, conn.dup_contact_list(), []); - } - }, - - _contactListChanged: function(conn, added, removed) { - for (let i = 0; i < added.length; i++) { - let contact = added[i]; - - contact.connect('subscription-states-changed', - Lang.bind(this, this._subscriptionStateChanged)); - this._subscriptionStateChanged(contact); - } - }, - - _subscriptionStateChanged: function(contact) { - if (contact.get_publish_state() != Tp.SubscriptionState.ASK) - return; - - /* Implicitly accept publish requests if contact is already subscribed */ - if (contact.get_subscribe_state() == Tp.SubscriptionState.YES || - contact.get_subscribe_state() == Tp.SubscriptionState.ASK) { - - contact.authorize_publication_async(function(src, result) { - src.authorize_publication_finish(result)}); - - return; - } - - /* Display notification to ask user to accept/reject request */ - let source = this._ensureSubscriptionSource(); - - let notif = new SubscriptionRequestNotification(source, contact); - source.notify(notif); - }, - - _ensureSubscriptionSource: function() { - if (this._subscriptionSource == null) { - this._subscriptionSource = new MessageTray.Source(_("Subscription request"), - 'gtk-dialog-question'); - Main.messageTray.add(this._subscriptionSource); - this._subscriptionSource.connect('destroy', Lang.bind(this, function () { - this._subscriptionSource = null; - })); - } - - return this._subscriptionSource; - }, - - _accountConnectionStatusNotifyCb: function(account) { - let connectionError = account.connection_error; - - if (account.connection_status != Tp.ConnectionStatus.DISCONNECTED || - connectionError == Tp.error_get_dbus_name(Tp.Error.CANCELLED)) { - return; - } - - let notif = this._accountNotifications[account.get_object_path()]; - if (notif) - return; - - /* Display notification that account failed to connect */ - let source = this._ensureAccountSource(); - - notif = new AccountNotification(source, account, connectionError); - this._accountNotifications[account.get_object_path()] = notif; - notif.connect('destroy', Lang.bind(this, function() { - delete this._accountNotifications[account.get_object_path()]; - })); - source.notify(notif); - }, - - _ensureAccountSource: function() { - if (this._accountSource == null) { - this._accountSource = new MessageTray.Source(_("Connection error"), - 'gtk-dialog-error'); - Main.messageTray.add(this._accountSource); - this._accountSource.connect('destroy', Lang.bind(this, function () { - this._accountSource = null; - })); - } - - return this._accountSource; - } -}); - -const ChatSource = new Lang.Class({ - Name: 'ChatSource', - Extends: MessageTray.Source, - - _init: function(account, conn, channel, contact, client) { - this._account = account; - this._contact = contact; - this._client = client; - - this.parent(contact.get_alias()); - - this.isChat = true; - this._pendingMessages = []; - - this._conn = conn; - this._channel = channel; - this._closedId = this._channel.connect('invalidated', Lang.bind(this, this._channelClosed)); - - this._notification = new ChatNotification(this); - this._notification.setUrgency(MessageTray.Urgency.HIGH); - this._notifyTimeoutId = 0; - - // We ack messages when the user expands the new notification or views the summary - // notification, in which case the notification is also expanded. - this._notification.connect('expanded', Lang.bind(this, this._ackMessages)); - - this._presence = contact.get_presence_type(); - - this._sentId = this._channel.connect('message-sent', Lang.bind(this, this._messageSent)); - this._receivedId = this._channel.connect('message-received', Lang.bind(this, this._messageReceived)); - this._pendingId = this._channel.connect('pending-message-removed', Lang.bind(this, this._pendingRemoved)); - - this._notifyAliasId = this._contact.connect('notify::alias', Lang.bind(this, this._updateAlias)); - this._notifyAvatarId = this._contact.connect('notify::avatar-file', Lang.bind(this, this._updateAvatarIcon)); - this._presenceChangedId = this._contact.connect('presence-changed', Lang.bind(this, this._presenceChanged)); - - // Add ourselves as a source. - Main.messageTray.add(this); - this.pushNotification(this._notification); - - this._getLogMessages(); - }, - - _updateAlias: function() { - let oldAlias = this.title; - let newAlias = this._contact.get_alias(); - - if (oldAlias == newAlias) - return; - - this.setTitle(newAlias); - this._notification.appendAliasChange(oldAlias, newAlias); - }, - - createIcon: function(size) { - this._iconBox = new St.Bin({ style_class: 'avatar-box' }); - this._iconBox._size = size; - let textureCache = St.TextureCache.get_default(); - let file = this._contact.get_avatar_file(); - - if (file) { - let uri = file.get_uri(); - this._iconBox.child = textureCache.load_uri_async(uri, this._iconBox._size, this._iconBox._size); - } else { - this._iconBox.child = new St.Icon({ icon_name: 'avatar-default', - icon_size: this._iconBox._size }); - } - - return this._iconBox; - }, - - createSecondaryIcon: function() { - let iconBox = new St.Bin(); - iconBox.child = new St.Icon({ style_class: 'secondary-icon' }); - let presenceType = this._contact.get_presence_type(); - - switch (presenceType) { - case Tp.ConnectionPresenceType.AVAILABLE: - iconBox.child.icon_name = 'user-available'; - break; - case Tp.ConnectionPresenceType.BUSY: - iconBox.child.icon_name = 'user-busy'; - break; - case Tp.ConnectionPresenceType.OFFLINE: - iconBox.child.icon_name = 'user-offline'; - break; - case Tp.ConnectionPresenceType.HIDDEN: - iconBox.child.icon_name = 'user-invisible'; - break; - case Tp.ConnectionPresenceType.AWAY: - iconBox.child.icon_name = 'user-away'; - break; - case Tp.ConnectionPresenceType.EXTENDED_AWAY: - iconBox.child.icon_name = 'user-idle'; - break; - default: - iconBox.child.icon_name = 'user-offline'; - } - return iconBox; - }, - - _updateAvatarIcon: function() { - this.iconUpdated(); - this._notification.update(this._notification.title, null, { customContent: true }); - }, - - open: function(notification) { - if (this._client.is_handling_channel(this._channel)) { - // We are handling the channel, try to pass it to Empathy - this._client.delegate_channels_async([this._channel], global.get_current_time(), '', null); - } - else { - // We are not the handler, just ask to present the channel - let dbus = Tp.DBusDaemon.dup(); - let cd = Tp.ChannelDispatcher.new(dbus); - - cd.present_channel_async(this._channel, global.get_current_time(), null); - } - }, - - _getLogMessages: function() { - let logManager = Tpl.LogManager.dup_singleton(); - let entity = Tpl.Entity.new_from_tp_contact(this._contact, Tpl.EntityType.CONTACT); - - logManager.get_filtered_events_async(this._account, entity, - Tpl.EventTypeMask.TEXT, SCROLLBACK_HISTORY_LINES, - null, Lang.bind(this, this._displayPendingMessages)); - }, - - _displayPendingMessages: function(logManager, result) { - let [success, events] = logManager.get_filtered_events_finish(result); - - let logMessages = events.map(makeMessageFromTplEvent); - - let pendingTpMessages = this._channel.get_pending_messages(); - let pendingMessages = []; - - for (let i = 0; i < pendingTpMessages.length; i++) { - let message = pendingTpMessages[i]; - - if (message.get_message_type() == Tp.ChannelTextMessageType.DELIVERY_REPORT) - continue; - - pendingMessages.push(makeMessageFromTpMessage(message, NotificationDirection.RECEIVED)); - - this._pendingMessages.push(message); - } - - this.countUpdated(); - - let showTimestamp = false; - - for (let i = 0; i < logMessages.length; i++) { - let logMessage = logMessages[i]; - let isPending = false; - - // Skip any log messages that are also in pendingMessages - for (let j = 0; j < pendingMessages.length; j++) { - let pending = pendingMessages[j]; - if (logMessage.timestamp == pending.timestamp && logMessage.text == pending.text) { - isPending = true; - break; - } - } - - if (!isPending) { - showTimestamp = true; - this._notification.appendMessage(logMessage, true, ['chat-log-message']); - } - } - - if (showTimestamp) - this._notification.appendTimestamp(); - - for (let i = 0; i < pendingMessages.length; i++) - this._notification.appendMessage(pendingMessages[i], true); - - if (pendingMessages.length > 0) - this.notify(); - }, - - _channelClosed: function() { - this._channel.disconnect(this._closedId); - this._channel.disconnect(this._receivedId); - this._channel.disconnect(this._pendingId); - this._channel.disconnect(this._sentId); - - this._contact.disconnect(this._notifyAliasId); - this._contact.disconnect(this._notifyAvatarId); - this._contact.disconnect(this._presenceChangedId); - - this.destroy(); - }, - - /* All messages are new messages for Telepathy sources */ - get count() { - return this._pendingMessages.length; - }, - - get unseenCount() { - return this.count; - }, - - get countVisible() { - return this.count > 0; - }, - - _messageReceived: function(channel, message) { - if (message.get_message_type() == Tp.ChannelTextMessageType.DELIVERY_REPORT) - return; - - this._pendingMessages.push(message); - this.countUpdated(); - - message = makeMessageFromTpMessage(message, NotificationDirection.RECEIVED); - this._notification.appendMessage(message); - - // Wait a bit before notifying for the received message, a handler - // could ack it in the meantime. - if (this._notifyTimeoutId != 0) - Mainloop.source_remove(this._notifyTimeoutId); - this._notifyTimeoutId = Mainloop.timeout_add(500, - Lang.bind(this, this._notifyTimeout)); - }, - - _notifyTimeout: function() { - if (this._pendingMessages.length != 0) - this.notify(); - - this._notifyTimeoutId = 0; - - return false; - }, - - // This is called for both messages we send from - // our client and other clients as well. - _messageSent: function(channel, message, flags, token) { - message = makeMessageFromTpMessage(message, NotificationDirection.SENT); - this._notification.appendMessage(message); - }, - - notify: function() { - this.parent(this._notification); - }, - - respond: function(text) { - let type; - if (text.slice(0, 4) == '/me ') { - type = Tp.ChannelTextMessageType.ACTION; - text = text.slice(4); - } else { - type = Tp.ChannelTextMessageType.NORMAL; - } - - let msg = Tp.ClientMessage.new_text(type, text); - this._channel.send_message_async(msg, 0, Lang.bind(this, function (src, result) { - this._channel.send_message_finish(result); - })); - }, - - setChatState: function(state) { - // We don't want to send COMPOSING every time a letter is typed into - // the entry. We send the state only when it changes. Telepathy/Empathy - // might change it behind our back if the user is using both - // gnome-shell's entry and the Empathy conversation window. We could - // keep track of it with the ChatStateChanged signal but it is good - // enough right now. - if (state != this._chatState) { - this._chatState = state; - this._channel.set_chat_state_async(state, null); - } - }, - - _presenceChanged: function (contact, presence, status, message) { - let msg, title; - - title = GLib.markup_escape_text(this.title, -1); - - this._notification.update(this._notification.title, null, { customContent: true, secondaryIcon: this.createSecondaryIcon() }); - - if (message) - msg += ' (' + GLib.markup_escape_text(message, -1) + ')'; - }, - - _pendingRemoved: function(channel, message) { - let idx = this._pendingMessages.indexOf(message); - - if (idx >= 0) { - this._pendingMessages.splice(idx, 1); - this.countUpdated(); - } - else - throw new Error('Message not in our pending list: ' + message); - }, - - _ackMessages: function() { - // Don't clear our messages here, tp-glib will send a - // 'pending-message-removed' for each one. - this._channel.ack_all_pending_messages_async(Lang.bind(this, function(src, result) { - this._channel.ack_all_pending_messages_finish(result);})); - } -}); - -const ChatNotification = new Lang.Class({ - Name: 'ChatNotification', - Extends: MessageTray.Notification, - - _init: function(source) { - this.parent(source, source.title, null, { customContent: true, secondaryIcon: source.createSecondaryIcon() }); - this.setResident(true); - - this._responseEntry = new St.Entry({ style_class: 'chat-response', - can_focus: true }); - this._responseEntry.clutter_text.connect('activate', Lang.bind(this, this._onEntryActivated)); - this._responseEntry.clutter_text.connect('text-changed', Lang.bind(this, this._onEntryChanged)); - this.setActionArea(this._responseEntry); - - this._oldMaxScrollAdjustment = 0; - this._createScrollArea(); - this._lastGroup = null; - this._lastGroupActor = null; - - this._scrollArea.vscroll.adjustment.connect('changed', Lang.bind(this, function(adjustment) { - let currentValue = adjustment.value + adjustment.page_size; - if (currentValue == this._oldMaxScrollAdjustment) - this.scrollTo(St.Side.BOTTOM); - this._oldMaxScrollAdjustment = adjustment.upper; - })); - - this._inputHistory = new History.HistoryManager({ entry: this._responseEntry.clutter_text }); - - this._history = []; - this._timestampTimeoutId = 0; - this._composingTimeoutId = 0; - }, - - /** - * appendMessage: - * @message: An object with the properties: - * text: the body of the message, - * messageType: a #Tp.ChannelTextMessageType, - * sender: the name of the sender, - * timestamp: the time the message was sent - * direction: a #NotificationDirection - * - * @noTimestamp: Whether to add a timestamp. If %true, no timestamp - * will be added, regardless of the difference since the - * last timestamp - */ - appendMessage: function(message, noTimestamp) { - let messageBody = GLib.markup_escape_text(message.text, -1); - let styles = [message.direction]; - - if (message.messageType == Tp.ChannelTextMessageType.ACTION) { - let senderAlias = GLib.markup_escape_text(message.sender, -1); - messageBody = '%s %s'.format(senderAlias, messageBody); - styles.push('chat-action'); - } - - if (message.direction == NotificationDirection.RECEIVED) { - this.update(this.source.title, messageBody, { customContent: true, - bannerMarkup: true }); - } - - let group = (message.direction == NotificationDirection.RECEIVED ? - 'received' : 'sent'); - - this._append({ body: messageBody, - group: group, - styles: styles, - timestamp: message.timestamp, - noTimestamp: noTimestamp }); - }, - - _filterMessages: function() { - if (this._history.length < 1) - return; - - let lastMessageTime = this._history[0].time; - let currentTime = (Date.now() / 1000); - - // Keep the scrollback from growing too long. If the most - // recent message (before the one we just added) is within - // SCROLLBACK_RECENT_TIME, we will keep - // SCROLLBACK_RECENT_LENGTH previous messages. Otherwise - // we'll keep SCROLLBACK_IDLE_LENGTH messages. - - let maxLength = (lastMessageTime < currentTime - SCROLLBACK_RECENT_TIME) ? - SCROLLBACK_IDLE_LENGTH : SCROLLBACK_RECENT_LENGTH; - - let filteredHistory = this._history.filter(function(item) { return item.realMessage }); - if (filteredHistory.length > maxLength) { - let lastMessageToKeep = filteredHistory[maxLength]; - let expired = this._history.splice(this._history.indexOf(lastMessageToKeep)); - for (let i = 0; i < expired.length; i++) - expired[i].actor.destroy(); - } - - let groups = this._contentArea.get_children(); - for (let i = 0; i < groups.length; i++) { - let group = groups[i]; - if (group.get_n_children() == 0) - group.destroy(); - } - }, - - /** - * _append: - * @props: An object with the properties: - * body: The text of the message. - * group: The group of the message, one of: - * 'received', 'sent', 'meta'. - * styles: Style class names for the message to have. - * timestamp: The timestamp of the message. - * noTimestamp: suppress timestamp signal? - * childProps: props to add the actor with. - */ - _append: function(props) { - let currentTime = (Date.now() / 1000); - props = Params.parse(props, { body: null, - group: null, - styles: [], - timestamp: currentTime, - noTimestamp: false, - childProps: null }); - - // Reset the old message timeout - if (this._timestampTimeoutId) - Mainloop.source_remove(this._timestampTimeoutId); - - let highlighter = new MessageTray.URLHighlighter(props.body, - true, // line wrap? - true); // allow markup? - - let body = highlighter.actor; - - let styles = props.styles; - for (let i = 0; i < styles.length; i++) - body.add_style_class_name(styles[i]); - - let group = props.group; - if (group != this._lastGroup) { - let style = 'chat-group-' + group; - this._lastGroup = group; - this._lastGroupActor = new St.BoxLayout({ style_class: style, - vertical: true }); - this.addActor(this._lastGroupActor); - } - - this._lastGroupActor.add(body, props.childProps); - - this.updated(); - - let timestamp = props.timestamp; - this._history.unshift({ actor: body, time: timestamp, - realMessage: group != 'meta' }); - - if (!props.noTimestamp) { - if (timestamp < currentTime - SCROLLBACK_IMMEDIATE_TIME) - this.appendTimestamp(); - else - // Schedule a new timestamp in SCROLLBACK_IMMEDIATE_TIME - // from the timestamp of the message. - this._timestampTimeoutId = Mainloop.timeout_add_seconds( - SCROLLBACK_IMMEDIATE_TIME - (currentTime - timestamp), - Lang.bind(this, this.appendTimestamp)); - } - - this._filterMessages(); - }, - - _formatTimestamp: function(date) { - let now = new Date(); - - var daysAgo = (now.getTime() - date.getTime()) / (24 * 60 * 60 * 1000); - - let format; - - // Show a week day and time if date is in the last week - if (daysAgo < 1 || (daysAgo < 7 && now.getDay() != date.getDay())) { - /* Translators: this is a time format string followed by a date. - If applicable, replace %X with a strftime format valid for your - locale, without seconds. */ - // xgettext:no-c-format - format = _("Sent at %X on %A"); - - } else if (date.getYear() == now.getYear()) { - /* Translators: this is a time format in the style of "Wednesday, May 25", - shown when you get a chat message in the same year. */ - // xgettext:no-c-format - format = _("Sent on %A, %B %d"); - } else { - /* Translators: this is a time format in the style of "Wednesday, May 25, 2012", - shown when you get a chat message in a different year. */ - // xgettext:no-c-format - format = _("Sent on %A, %B %d, %Y"); - } - - return date.toLocaleFormat(format); - }, - - appendTimestamp: function() { - let lastMessageTime = this._history[0].time; - let lastMessageDate = new Date(lastMessageTime * 1000); - - let timeLabel = this._append({ body: this._formatTimestamp(lastMessageDate), - group: 'meta', - styles: ['chat-meta-message'], - childProps: { expand: true, x_fill: false, - x_align: St.Align.END }, - noTimestamp: true, - timestamp: lastMessageTime }); - - this._filterMessages(); - - return false; - }, - - appendAliasChange: function(oldAlias, newAlias) { - oldAlias = GLib.markup_escape_text(oldAlias, -1); - newAlias = GLib.markup_escape_text(newAlias, -1); - - /* Translators: this is the other person changing their old IM name to their new - IM name. */ - let message = '' + _("%s is now known as %s").format(oldAlias, newAlias) + ''; - - let label = this._append({ body: message, - group: 'meta', - styles: ['chat-meta-message'] }); - - this.update(newAlias, null, { customContent: true }); - - this._filterMessages(); - }, - - _onEntryActivated: function() { - let text = this._responseEntry.get_text(); - if (text == '') - return; - - this._inputHistory.addItem(text); - - // Telepathy sends out the Sent signal for us. - // see Source._messageSent - this._responseEntry.set_text(''); - this.source.respond(text); - }, - - _composingStopTimeout: function() { - this._composingTimeoutId = 0; - - this.source.setChatState(Tp.ChannelChatState.PAUSED); - - return false; - }, - - _onEntryChanged: function() { - let text = this._responseEntry.get_text(); - - // If we're typing, we want to send COMPOSING. - // If we empty the entry, we want to send ACTIVE. - // If we've stopped typing for COMPOSING_STOP_TIMEOUT - // seconds, we want to send PAUSED. - - // Remove composing timeout. - if (this._composingTimeoutId > 0) { - Mainloop.source_remove(this._composingTimeoutId); - this._composingTimeoutId = 0; - } - - if (text != '') { - this.source.setChatState(Tp.ChannelChatState.COMPOSING); - - this._composingTimeoutId = Mainloop.timeout_add_seconds( - COMPOSING_STOP_TIMEOUT, - Lang.bind(this, this._composingStopTimeout)); - } else { - this.source.setChatState(Tp.ChannelChatState.ACTIVE); - } - } -}); - -const ApproverSource = new Lang.Class({ - Name: 'ApproverSource', - Extends: MessageTray.Source, - - _init: function(dispatchOp, text, gicon) { - this._gicon = gicon; - - this.parent(text); - - this._dispatchOp = dispatchOp; - - // Destroy the source if the channel dispatch operation is invalidated - // as we can't approve any more. - this._invalidId = dispatchOp.connect('invalidated', - Lang.bind(this, function(domain, code, msg) { - this.destroy(); - })); - }, - - destroy: function() { - if (this._invalidId != 0) { - this._dispatchOp.disconnect(this._invalidId); - this._invalidId = 0; - } - - this.parent(); - }, - - createIcon: function(size) { - return new St.Icon({ gicon: this._gicon, - icon_size: size }); - } -}); - -const RoomInviteNotification = new Lang.Class({ - Name: 'RoomInviteNotification', - Extends: MessageTray.Notification, - - _init: function(source, dispatchOp, channel, inviter) { - this.parent(source, - /* translators: argument is a room name like - * room@jabber.org for example. */ - _("Invitation to %s").format(channel.get_identifier()), - null, - { customContent: true }); - this.setResident(true); - - /* translators: first argument is the name of a contact and the second - * one the name of a room. "Alice is inviting you to join room@jabber.org - * for example. */ - this.addBody(_("%s is inviting you to join %s").format(inviter.get_alias(), channel.get_identifier())); - - this.addButton('decline', _("Decline")); - this.addButton('accept', _("Accept")); - - this.connect('action-invoked', Lang.bind(this, function(self, action) { - switch (action) { - case 'decline': - dispatchOp.leave_channels_async(Tp.ChannelGroupChangeReason.NONE, - '', function(src, result) { - src.leave_channels_finish(result)}); - break; - case 'accept': - dispatchOp.handle_with_time_async('', global.get_current_time(), - function(src, result) { - src.handle_with_time_finish(result)}); - break; - } - this.destroy(); - })); - } -}); - -// Audio Video -const AudioVideoNotification = new Lang.Class({ - Name: 'AudioVideoNotification', - Extends: MessageTray.Notification, - - _init: function(source, dispatchOp, channel, contact, isVideo) { - let title = ''; - - if (isVideo) - /* translators: argument is a contact name like Alice for example. */ - title = _("Video call from %s").format(contact.get_alias()); - else - /* translators: argument is a contact name like Alice for example. */ - title = _("Call from %s").format(contact.get_alias()); - - this.parent(source, title, null, { customContent: true }); - this.setResident(true); - - this.addButton('reject', _("Reject")); - /* translators: this is a button label (verb), not a noun */ - this.addButton('answer', _("Answer")); - - this.connect('action-invoked', Lang.bind(this, function(self, action) { - switch (action) { - case 'reject': - dispatchOp.leave_channels_async(Tp.ChannelGroupChangeReason.NONE, - '', function(src, result) { - src.leave_channels_finish(result)}); - break; - case 'answer': - dispatchOp.handle_with_time_async('', global.get_current_time(), - function(src, result) { - src.handle_with_time_finish(result)}); - break; - } - this.destroy(); - })); - } -}); - -// File Transfer -const FileTransferNotification = new Lang.Class({ - Name: 'FileTransferNotification', - Extends: MessageTray.Notification, - - _init: function(source, dispatchOp, channel, contact) { - this.parent(source, - /* To translators: The first parameter is - * the contact's alias and the second one is the - * file name. The string will be something - * like: "Alice is sending you test.ogg" - */ - _("%s is sending you %s").format(contact.get_alias(), - channel.get_filename()), - null, - { customContent: true }); - this.setResident(true); - - this.addButton('decline', _("Decline")); - this.addButton('accept', _("Accept")); - - this.connect('action-invoked', Lang.bind(this, function(self, action) { - switch (action) { - case 'decline': - dispatchOp.leave_channels_async(Tp.ChannelGroupChangeReason.NONE, - '', function(src, result) { - src.leave_channels_finish(result)}); - break; - case 'accept': - dispatchOp.handle_with_time_async('', global.get_current_time(), - function(src, result) { - src.handle_with_time_finish(result)}); - break; - } - this.destroy(); - })); - } -}); - -// Subscription request -const SubscriptionRequestNotification = new Lang.Class({ - Name: 'SubscriptionRequestNotification', - Extends: MessageTray.Notification, - - _init: function(source, contact) { - this.parent(source, - /* To translators: The parameter is the contact's alias */ - _("%s would like permission to see when you are online").format(contact.get_alias()), - null, { customContent: true }); - - this._contact = contact; - this._connection = contact.get_connection(); - - let layout = new St.BoxLayout({ vertical: false }); - - // Display avatar - let iconBox = new St.Bin({ style_class: 'avatar-box' }); - iconBox._size = 48; - - let textureCache = St.TextureCache.get_default(); - let file = contact.get_avatar_file(); - - if (file) { - let uri = file.get_uri(); - iconBox.child = textureCache.load_uri_async(uri, iconBox._size, iconBox._size); - } - else { - iconBox.child = new St.Icon({ icon_name: 'avatar-default', - icon_size: iconBox._size }); - } - - layout.add(iconBox); - - // subscription request message - let label = new St.Label({ style_class: 'subscription-message', - text: contact.get_publish_request() }); - - layout.add(label); - - this.addActor(layout); - - this.addButton('decline', _("Decline")); - this.addButton('accept', _("Accept")); - - this.connect('action-invoked', Lang.bind(this, function(self, action) { - switch (action) { - case 'decline': - contact.remove_async(function(src, result) { - src.remove_finish(result)}); - break; - case 'accept': - // Authorize the contact and request to see his status as well - contact.authorize_publication_async(function(src, result) { - src.authorize_publication_finish(result)}); - - contact.request_subscription_async('', function(src, result) { - src.request_subscription_finish(result)}); - break; - } - - // rely on _subscriptionStatesChangedCb to destroy the - // notification - })); - - this._changedId = contact.connect('subscription-states-changed', - Lang.bind(this, this._subscriptionStatesChangedCb)); - this._invalidatedId = this._connection.connect('invalidated', - Lang.bind(this, this.destroy)); - }, - - destroy: function() { - if (this._changedId != 0) { - this._contact.disconnect(this._changedId); - this._changedId = 0; - } - - if (this._invalidatedId != 0) { - this._connection.disconnect(this._invalidatedId); - this._invalidatedId = 0; - } - - this.parent(); - }, - - _subscriptionStatesChangedCb: function(contact, subscribe, publish, msg) { - // Destroy the notification if the subscription request has been - // answered - if (publish != Tp.SubscriptionState.ASK) - this.destroy(); - } -}); - -// Messages from empathy/libempathy/empathy-utils.c -// create_errors_to_message_hash() - -/* Translator note: these should be the same messages that are - * used in Empathy, so just copy and paste from there. */ -let _connectionErrorMessages = {}; -_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.NETWORK_ERROR)] - = _("Network error"); -_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.AUTHENTICATION_FAILED)] - = _("Authentication failed"); -_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.ENCRYPTION_ERROR)] - = _("Encryption error"); -_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CERT_NOT_PROVIDED)] - = _("Certificate not provided"); -_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CERT_UNTRUSTED)] - = _("Certificate untrusted"); -_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CERT_EXPIRED)] - = _("Certificate expired"); -_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CERT_NOT_ACTIVATED)] - = _("Certificate not activated"); -_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CERT_HOSTNAME_MISMATCH)] - = _("Certificate hostname mismatch"); -_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CERT_FINGERPRINT_MISMATCH)] - = _("Certificate fingerprint mismatch"); -_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CERT_SELF_SIGNED)] - = _("Certificate self-signed"); -_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CANCELLED)] - = _("Status is set to offline"); -_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.ENCRYPTION_NOT_AVAILABLE)] - = _("Encryption is not available"); -_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CERT_INVALID)] - = _("Certificate is invalid"); -_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CONNECTION_REFUSED)] - = _("Connection has been refused"); -_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CONNECTION_FAILED)] - = _("Connection can't be established"); -_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CONNECTION_LOST)] - = _("Connection has been lost"); -_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.ALREADY_CONNECTED)] - = _("This account is already connected to the server"); -_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CONNECTION_REPLACED)] - = _("Connection has been replaced by a new connection using the same resource"); -_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.REGISTRATION_EXISTS)] - = _("The account already exists on the server"); -_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.SERVICE_BUSY)] - = _("Server is currently too busy to handle the connection"); -_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CERT_REVOKED)] - = _("Certificate has been revoked"); -_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CERT_INSECURE)] - = _("Certificate uses an insecure cipher algorithm or is cryptographically weak"); -_connectionErrorMessages[Tp.error_get_dbus_name(Tp.Error.CERT_LIMIT_EXCEEDED)] - = _("The length of the server certificate, or the depth of the server certificate chain, exceed the limits imposed by the cryptography library"); -_connectionErrorMessages['org.freedesktop.DBus.Error.NoReply'] - = _("Internal error"); - -const AccountNotification = new Lang.Class({ - Name: 'AccountNotification', - Extends: MessageTray.Notification, - - _init: function(source, account, connectionError) { - this.parent(source, - /* translators: argument is the account name, like - * name@jabber.org for example. */ - _("Connection to %s failed").format(account.get_display_name()), - null, { customContent: true }); - - this._label = new St.Label(); - this.addActor(this._label); - this._updateMessage(connectionError); - - this._account = account; - - this.addButton('reconnect', _("Reconnect")); - this.addButton('edit', _("Edit account")); - - this.connect('action-invoked', Lang.bind(this, function(self, action) { - switch (action) { - case 'reconnect': - // If it fails again, a new notification should pop up with the - // new error. - account.reconnect_async(null); - break; - case 'edit': - let cmd = '/usr/bin/empathy-accounts' - + ' --select-account=%s' - .format(account.get_path_suffix()); - let app_info = Gio.app_info_create_from_commandline(cmd, null, 0); - app_info.launch([], global.create_app_launch_context()); - break; - } - this.destroy(); - })); - - this._enabledId = account.connect('notify::enabled', - Lang.bind(this, function() { - if (!account.is_enabled()) - this.destroy(); - })); - - this._invalidatedId = account.connect('invalidated', - Lang.bind(this, this.destroy)); - - this._connectionStatusId = account.connect('notify::connection-status', - Lang.bind(this, function() { - let status = account.connection_status; - if (status == Tp.ConnectionStatus.CONNECTED) { - this.destroy(); - } else if (status == Tp.ConnectionStatus.DISCONNECTED) { - this._updateMessage(account.connection_error); - } - })); - }, - - _updateMessage: function(connectionError) { - let message; - if (connectionError in _connectionErrorMessages) { - message = _connectionErrorMessages[connectionError]; - } else { - message = _("Unknown reason"); - } - this._label.set_text(message); - }, - - destroy: function() { - if (this._enabledId != 0) { - this._account.disconnect(this._enabledId); - this._enabledId = 0; - } - - if (this._invalidatedId != 0) { - this._account.disconnect(this._invalidatedId); - this._invalidatedId = 0; - } - - if (this._connectionStatusId != 0) { - this._account.disconnect(this._connectionStatusId); - this._connectionStatusId = 0; - } - - this.parent(); - } -}); diff --git a/src/shell-polkit-authentication-agent.c b/src/shell-polkit-authentication-agent.c index e85354a68..f1e8bf6d0 100644 --- a/src/shell-polkit-authentication-agent.c +++ b/src/shell-polkit-authentication-agent.c @@ -62,8 +62,9 @@ struct _ShellPolkitAuthenticationAgent { PolkitAgentListener parent_instance; GList *scheduled_requests; - AuthRequest *current_request; + + gpointer handle; }; /* Signals */ @@ -93,55 +94,40 @@ static gboolean initiate_authentication_finish (PolkitAgentListener *listener, GAsyncResult *res, GError **error); -static void +void shell_polkit_authentication_agent_init (ShellPolkitAuthenticationAgent *agent) { - gpointer handle; - PolkitSubject *subject; - GError *error; +} - subject = NULL; +void +shell_polkit_authentication_agent_register (ShellPolkitAuthenticationAgent *agent, + GError **error_out) +{ + GError *error = NULL; + PolkitSubject *subject; - error = NULL; subject = polkit_unix_session_new_for_process_sync (getpid (), NULL, /* GCancellable* */ &error); if (subject == NULL) { - if (error) /* polkit version 104 and older don't properly set error on failure */ - { - g_warning ("Error getting session for the process we are in: %s (%s %d)", - error->message, - g_quark_to_string (error->domain), - error->code); - g_error_free (error); - } - else - { - g_warning ("Error getting session for the process we are in"); - } + if (error == NULL) /* polkit version 104 and older don't properly set error on failure */ + error = g_error_new (POLKIT_ERROR, POLKIT_ERROR_FAILED, + "PolKit failed to properly get our session"); goto out; } - handle = polkit_agent_listener_register (POLKIT_AGENT_LISTENER (agent), - POLKIT_AGENT_REGISTER_FLAGS_NONE, - subject, - NULL, /* use default object path */ - NULL, /* GCancellable */ - &error); - if (handle == NULL) - { - g_warning ("Error registering polkit authentication agent: %s (%s %d)", - error->message, - g_quark_to_string (error->domain), - error->code); - g_error_free (error); - goto out; - } - - /* We don't need to register so skip saving handle */ + agent->handle = polkit_agent_listener_register (POLKIT_AGENT_LISTENER (agent), + POLKIT_AGENT_REGISTER_FLAGS_NONE, + subject, + NULL, /* use default object path */ + NULL, /* GCancellable */ + &error); out: + if (error != NULL) + g_propagate_error (error_out, error); + if (subject != NULL) g_object_unref (subject); } @@ -149,12 +135,8 @@ shell_polkit_authentication_agent_init (ShellPolkitAuthenticationAgent *agent) static void shell_polkit_authentication_agent_finalize (GObject *object) { - /* ShellPolkitAuthenticationAgent *agent = SHELL_POLKIT_AUTHENTICATION_AGENT (object); */ - - /* Specifically left empty since the object stays alive forever - if code - * is reused it would need to free outstanding requests etc. - */ - + ShellPolkitAuthenticationAgent *agent = SHELL_POLKIT_AUTHENTICATION_AGENT (object); + shell_polkit_authentication_agent_unregister (agent); G_OBJECT_CLASS (shell_polkit_authentication_agent_parent_class)->finalize (object); } @@ -325,6 +307,27 @@ on_request_cancelled (GCancellable *cancellable, g_idle_add (handle_cancelled_in_idle, request); } +static void +auth_request_dismiss (AuthRequest *request) +{ + auth_request_complete (request, TRUE); +} + +void +shell_polkit_authentication_agent_unregister (ShellPolkitAuthenticationAgent *agent) +{ + if (agent->scheduled_requests != NULL) + { + g_list_foreach (agent->scheduled_requests, (GFunc)auth_request_dismiss, NULL); + agent->scheduled_requests = NULL; + } + if (agent->current_request != NULL) + auth_request_dismiss (agent->current_request); + + polkit_agent_listener_unregister (agent->handle); + agent->handle = NULL; +} + static void maybe_process_next_request (ShellPolkitAuthenticationAgent *agent); static void diff --git a/src/shell-polkit-authentication-agent.h b/src/shell-polkit-authentication-agent.h index a8758ae92..745f0c983 100644 --- a/src/shell-polkit-authentication-agent.h +++ b/src/shell-polkit-authentication-agent.h @@ -27,6 +27,9 @@ GType shell_polkit_authentication_agent_get_type (void ShellPolkitAuthenticationAgent *shell_polkit_authentication_agent_new (void); void shell_polkit_authentication_agent_complete (ShellPolkitAuthenticationAgent *agent, gboolean dismissed); +void shell_polkit_authentication_agent_register (ShellPolkitAuthenticationAgent *agent, + GError **error_out); +void shell_polkit_authentication_agent_unregister (ShellPolkitAuthenticationAgent *agent); G_END_DECLS